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::{BufRead, BufReader, Read, Write},
15    path::{Path, PathBuf},
16    sync::Arc,
17};
18mod assets;
19pub(crate) mod boot_system_wal;
20mod catalog;
21pub(crate) mod catalog_aliases;
22mod internal_metadata;
23#[cfg(test)]
24pub(crate) mod legacy_fixture;
25mod salvage;
26mod scrub;
27mod source;
28use catalog::Catalog;
29pub use salvage::{
30    salvage_paged_collections, salvage_paged_indexes, PagedCollectionLoss,
31    PagedCollectionSalvageReport, PagedIndexSalvageFinding, PagedIndexSalvageReport,
32};
33pub use scrub::{
34    scrub_paged, scrub_paged_trees, PagedScrubReport, PagedTreeFinding, PagedTreeScrubReport,
35};
36use source::Source;
37type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
38
39#[derive(Debug)]
40pub struct CompactionReport {
41    pub source_bytes: u64,
42    pub destination_bytes: u64,
43    pub source_allocated_bytes: Option<u64>,
44    pub destination_allocated_bytes: Option<u64>,
45    /// Includes exact persisted key and secondary-index records.
46    pub records: u64,
47    pub trees: usize,
48    /// Historical stale backlinks reconstructed from validated forward/owner order.
49    pub repaired_leaf_backlinks: u64,
50    /// Only operator-selected canonical recovery can retire boot-derived WAL actions.
51    pub retired_boot_system_wal_actions: usize,
52    pub reconciled_system_collections: usize,
53    pub imported_system_records: usize,
54}
55
56impl CompactionReport {
57    pub fn machine_json(&self) -> String {
58        format!(
59            r#"{{"source_bytes":{},"destination_bytes":{},"records":{},"trees":{},"repaired_leaf_backlinks":{},"retired_boot_system_wal_actions":{},"reconciled_system_collections":{},"imported_system_records":{},"source_allocated_bytes":{},"destination_allocated_bytes":{}}}"#,
60            self.source_bytes,
61            self.destination_bytes,
62            self.records,
63            self.trees,
64            self.repaired_leaf_backlinks,
65            self.retired_boot_system_wal_actions,
66            self.reconciled_system_collections,
67            self.imported_system_records,
68            self.source_allocated_bytes
69                .map_or_else(|| "null".into(), |n| n.to_string()),
70            self.destination_allocated_bytes
71                .map_or_else(|| "null".into(), |n| n.to_string())
72        )
73    }
74}
75
76/// Rewrite a closed, checkpointed paged file into an exclusively-created output
77/// directory. On failure the source is unchanged and the incomplete output is
78/// retained for diagnosis. This does not switch a serving database or vacuum MVCC.
79pub fn compact_paged(source: &Path, destination: &Path) -> Result<CompactionReport> {
80    compact_paged_inner(source, destination, None, None, false)
81}
82
83/// Rewrite a closed store while explicitly retiring the obsolete physical
84/// `red_config` identity. The canonical `red.config` tree is preserved as-is;
85/// the source remains unchanged and ordinary compaction never enables this.
86pub fn compact_paged_canonical_system_config(
87    source: &Path,
88    destination: &Path,
89) -> Result<CompactionReport> {
90    compact_paged_inner(source, destination, None, None, true)
91}
92
93/// Replay a supported pending unified WAL only against an isolated copy, then
94/// publish a validated compacted database. The locked source is never opened
95/// by a writer. Unsupported WAL families and ambiguous recovery fail closed.
96pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
97    recover_paged_wal_inner(source, destination, false)
98}
99
100/// Recover an isolated copy while explicitly retaining `red.config`.
101/// WAL actions targeting the divergent `red_config` are refused, except for
102/// proven process metadata recreated at boot. Those actions are retired without
103/// retargeting their entity IDs; every other committed action is replayed.
104pub fn recover_paged_wal_canonical_system_config(
105    source: &Path,
106    destination: &Path,
107) -> Result<CompactionReport> {
108    recover_paged_wal_inner(source, destination, true)
109}
110
111fn recover_paged_wal_inner(
112    source: &Path,
113    destination: &Path,
114    retain_canonical_system_config: bool,
115) -> Result<CompactionReport> {
116    use crate::storage::wal::{WalReader, WalRecord};
117    let source = source.canonicalize()?;
118    let parent = destination
119        .parent()
120        .filter(|p| !p.as_os_str().is_empty())
121        .unwrap_or_else(|| Path::new("."))
122        .canonicalize()?;
123    for owned in [
124        f::support_dir_for(&source),
125        source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
126    ] {
127        if owned.exists() {
128            ensure(
129                !parent.starts_with(owned.canonicalize()?),
130                "destination is inside source storage",
131            )?;
132        }
133    }
134    let guard = Arc::new(Source::open_for_wal_recovery(&source)?);
135    guard.verify_superblocks()?;
136    salvage::verify_wal_recovery_sidecars(&source)?;
137    let wal = f::unified_wal_path(&source);
138    let engine_wal = f::engine_wal_path(&source);
139    let pending_unified = wal_has_records(&wal)?;
140    let pending_engine = wal_has_records(&engine_wal)?;
141    ensure(
142        pending_unified
143            || pending_engine
144            || guard.pending_double_write
145            || guard.header.checkpoint_in_progress,
146        "no pending recovery",
147    )?;
148    ensure(
149        !guard.header.checkpoint_in_progress || pending_engine,
150        "interrupted page checkpoint requires its engine WAL",
151    )?;
152    let mut boot_wal =
153        if retain_canonical_system_config && pending_unified && !guard.pending_double_write {
154            boot_system_wal::BootSystemWalPlan::prepare(&guard, &wal, pending_engine)?
155        } else {
156            boot_system_wal::BootSystemWalPlan::default()
157        };
158    let pending_ownership = if pending_unified {
159        verify_wal_recovery_records(
160            &wal,
161            retain_canonical_system_config && !guard.pending_double_write,
162            &boot_wal,
163        )?
164    } else {
165        false
166    };
167    if !guard.pending_double_write
168        && !guard.header.checkpoint_in_progress
169        && !pending_ownership
170        && !pending_engine
171    {
172        salvage::verify_vault(&source, &guard)?;
173    }
174    let mut recovered_engine_xid = 0;
175    if pending_engine {
176        for entry in WalReader::open(&engine_wal)?.iter() {
177            match entry?.1 {
178                WalRecord::Begin { .. }
179                | WalRecord::Commit { .. }
180                | WalRecord::Rollback { .. }
181                | WalRecord::Checkpoint { .. } => {}
182                WalRecord::PageWrite { page_id, data, .. } => {
183                    ensure(
184                        page_id > 0
185                            && !(DWB_ZONE_START_PAGE..FIRST_ALLOCATABLE_PAGE).contains(&page_id)
186                            && u64::from(page_id) < guard.bytes.div_ceil(PAGE_SIZE as u64)
187                            && data.len() == PAGE_SIZE,
188                        "invalid engine WAL page",
189                    )?;
190                    let page = Page::from_slice(&data)?;
191                    ensure(
192                        page.page_id() == page_id,
193                        "engine WAL page identity mismatch",
194                    )?;
195                    page.page_type()?;
196                    // Engine transactions log page bytes before Pager::write_page
197                    // updates the page checksum. The WAL frame itself protects
198                    // the image; redo will seal the page before writing it.
199                }
200                _ => return Err("unsupported engine WAL record".into()),
201            }
202        }
203    }
204    std::fs::create_dir(destination)?;
205    #[cfg(unix)]
206    {
207        use std::os::unix::fs::PermissionsExt;
208        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
209    }
210    let stage = destination.join(".wal-replay");
211    std::fs::create_dir(&stage)?;
212    let name = source.file_name().ok_or("source filename is missing")?;
213    let staged_source = stage.join(name);
214    guard.copy_snapshot(&staged_source)?;
215    copy_sidecars(&source, &staged_source, true)?;
216    copy_physical_metadata_files(&source, &staged_source)?;
217    let preserved_audit = detach_recovery_audit_logs(&staged_source, &stage)?;
218    let config = crate::storage::unified::UnifiedStoreConfig {
219        resident_memory_bytes_max: guard
220            .header
221            .requires_disk_residency()
222            .then_some(64 * 1024 * 1024),
223        ..Default::default()
224    };
225    if pending_engine {
226        let pager = Pager::open(
227            &staged_source,
228            PagerConfig {
229                disk_residency: config.resident_memory_bytes_max.is_some(),
230                ..PagerConfig::default()
231            },
232        )?;
233        let wal = f::engine_wal_path(&staged_source);
234        recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
235        let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
236        writer.truncate()?;
237        writer.sync()?;
238    }
239    if retain_canonical_system_config && pending_unified && guard.pending_double_write {
240        // Restore the copy's DWB before trusting catalog roots or deletion IDs.
241        // The preserved source and its pending frame remain untouched.
242        {
243            let pager = Pager::open(
244                &staged_source,
245                PagerConfig {
246                    disk_residency: config.resident_memory_bytes_max.is_some(),
247                    ..PagerConfig::default()
248                },
249            )?;
250            pager.sync()?;
251        }
252        let checkpoint = Arc::new(Source::open_for_wal_recovery(&staged_source)?);
253        let staged_wal = f::unified_wal_path(&staged_source);
254        boot_wal =
255            boot_system_wal::BootSystemWalPlan::prepare(&checkpoint, &staged_wal, pending_engine)?;
256        verify_wal_recovery_records(&staged_wal, true, &boot_wal)?;
257    }
258    let catalog_plan = if retain_canonical_system_config {
259        catalog_aliases::CanonicalCatalogPlan::prepare(
260            &staged_source,
261            &f::unified_wal_path(&staged_source),
262        )?
263    } else {
264        catalog_aliases::CanonicalCatalogPlan::default()
265    };
266    let probabilistic_deltas;
267    let mut has_vector_cache = false;
268    {
269        let store = crate::storage::unified::UnifiedStore::open_with_config_for_recovery(
270            &staged_source,
271            config,
272            retain_canonical_system_config,
273            Some(&boot_wal),
274            Some(&catalog_plan),
275        )?;
276        boot_wal.preserve_identity_floors(&store)?;
277        catalog_plan.restore(&store)?;
278        if pending_engine || guard.pending_double_write {
279            store.reconcile_physical_recovery()?;
280        }
281        if pending_unified || pending_engine || guard.pending_double_write {
282            // Replayed physical pages or logical overlays may contain newer
283            // identifiers than the old catalog. Capture their floors before
284            // checkpoint turns the overlay into an immutable base.
285            let mut high = store
286                .startup_xid_high_water_mark()
287                .unwrap_or(0)
288                .max(recovered_engine_xid);
289            for name in store.list_collections() {
290                let manager = store
291                    .get_collection(&name)
292                    .ok_or("recovered collection disappeared")?;
293                let mut max_entity = 0u64;
294                let mut max_row = 0u64;
295                let mut max_sequence = 0u64;
296                let mut has_entity = false;
297                manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
298                    has_entity = true;
299                    high = high.max(entity.max_transaction_id());
300                    max_entity = max_entity.max(entity.id.raw());
301                    max_sequence = max_sequence.max(entity.sequence_id);
302                    if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
303                        max_row = max_row.max(*row_id);
304                    }
305                    true
306                })?;
307                if !has_entity {
308                    continue;
309                }
310                let entity_next = max_entity
311                    .checked_add(1)
312                    .ok_or("recovered entity IDs are exhausted")?;
313                let row_next = max_row
314                    .checked_add(1)
315                    .ok_or("recovered row IDs are exhausted")?;
316                let sequence_next = max_sequence
317                    .checked_add(1)
318                    .ok_or("recovered sequence IDs are exhausted")?;
319                manager.restore_identity_counters(entity_next, row_next);
320                manager.restore_sequence_id_next(sequence_next);
321                store.register_entity_id(crate::storage::EntityId::new(max_entity));
322            }
323            ensure(
324                high < u64::MAX,
325                "recovered transaction high-water mark is exhausted",
326            )?;
327            store.remember_startup_xid_high_water_mark(high);
328        }
329        // Turbo WAL is an index projection, while the persisted entity owns
330        // its content and metadata. Do not discard a vector that cannot be
331        // rebuilt exactly from the recovered entities.
332        for (collection, records) in store.replayed_turbo_inserts.lock().iter() {
333            has_vector_cache = true;
334            ensure(
335                crate::runtime::vector_turbo_kind::is_turbo(&store, collection),
336                "WAL vector collection is not a turbo collection",
337            )?;
338            for (id, vector) in records {
339                let entity = store
340                    .try_get(
341                        collection,
342                        crate::storage::EntityId::new(*id),
343                        32 * 1024 * 1024,
344                    )?
345                    .ok_or("WAL vector has no recovered entity")?;
346                let crate::storage::EntityData::Vector(data) = entity.data else {
347                    return Err("WAL vector points to a non-vector entity".into());
348                };
349                ensure(
350                    data.dense.len() == vector.len()
351                        && data
352                            .dense
353                            .iter()
354                            .zip(vector)
355                            .all(|(a, b)| a.to_bits() == b.to_bits()),
356                    "WAL vector differs from recovered entity",
357                )?;
358            }
359        }
360        has_vector_cache |= verify_turbo_snapshots(&staged_source, &store)?;
361        probabilistic_deltas = store.take_replayed_probabilistic_deltas();
362        store.persist()?;
363    }
364    if !probabilistic_deltas.is_empty() {
365        let options = crate::RedDBOptions::persistent(&staged_source)
366            .with_memory_budget(64 * 1024 * 1024)
367            .with_bounded_disk_residency(guard.header.requires_disk_residency());
368        let runtime = crate::RedDBRuntime::with_options(options)?;
369        runtime.persist_offline_probabilistic_recovery(probabilistic_deltas)?;
370        runtime.checkpoint()?;
371        runtime.graceful_shutdown(false)?;
372    }
373    if has_vector_cache {
374        scrub::verify_recovery_vector_pages(&staged_source)?;
375    }
376    let validated_dir = stage.join("validated");
377    let mut report = compact_paged(&staged_source, &validated_dir)?;
378    report.retired_boot_system_wal_actions = boot_wal.action_count();
379    report.reconciled_system_collections = catalog_plan.reconciled_count();
380    report.imported_system_records = catalog_plan.imported_count();
381    let verified = scrub_paged(&validated_dir.join(name))?;
382    ensure(
383        verified.assets_verified
384            && verified.trees.findings.is_empty()
385            && verified.unclassified_pages == 0,
386        "replayed store failed scrub",
387    )?;
388    if let Some(audit) = preserved_audit {
389        restore_recovery_audit_logs(&audit, &validated_dir.join(name))?;
390    }
391    let final_path = destination.join(name);
392    copy_sidecars(&validated_dir.join(name), &final_path, false)?;
393    copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
394    write_new(
395        &destination.join("compaction.json"),
396        report.machine_json().as_bytes(),
397    )?;
398    sync_directory(destination)?;
399    std::fs::hard_link(validated_dir.join(name), &final_path)?;
400    sync_directory(destination)?;
401    // The published database must not retain a second pathname with a
402    // different WAL family: future opens through that name would be unsafe.
403    std::fs::remove_file(validated_dir.join(name))?;
404    sync_directory(&validated_dir)?;
405    Ok(report)
406}
407
408fn verify_wal_recovery_records(
409    wal: &Path,
410    retain_canonical_system_config: bool,
411    boot_wal: &boot_system_wal::BootSystemWalPlan,
412) -> Result<bool> {
413    use crate::storage::wal::{WalReader, WalRecord};
414    let mut pending_ownership = false;
415    for entry in WalReader::open(wal)?.iter() {
416        let (lsn, record) = entry?;
417        match record {
418            WalRecord::PageWrite { data, .. } => {
419                verify_wal_recovery_action(&data, retain_canonical_system_config)?
420            }
421            WalRecord::TxCommitBatch { actions, .. } => {
422                for (index, action) in actions.iter().enumerate() {
423                    if !boot_wal.skips_action(lsn, index) {
424                        verify_wal_recovery_action(action, retain_canonical_system_config)?;
425                    }
426                }
427            }
428            WalRecord::VectorInsert { collection, .. } => {
429                ensure(
430                    !retain_canonical_system_config || collection != "red_config",
431                    "WAL references divergent legacy red_config",
432                )?;
433            }
434            WalRecord::ProbabilisticDelta { name, .. } => {
435                ensure(
436                    !retain_canonical_system_config || name != "red_config",
437                    "WAL references divergent legacy red_config",
438                )?;
439            }
440            WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
441                pending_ownership = true
442            }
443            _ => {}
444        }
445    }
446    Ok(pending_ownership)
447}
448
449fn verify_wal_recovery_action(bytes: &[u8], retain_canonical_system_config: bool) -> Result<()> {
450    let action = f::decode_store_wal_action_frame(bytes)?;
451    if retain_canonical_system_config {
452        let name = match &action {
453            f::StoreWalActionFrame::CreateCollection { name }
454            | f::StoreWalActionFrame::DropCollection { name } => Some(name),
455            f::StoreWalActionFrame::UpsertEntityRecord { collection, .. }
456            | f::StoreWalActionFrame::DeleteEntityRecord { collection, .. }
457            | f::StoreWalActionFrame::BulkUpsertEntityRecords { collection, .. }
458            | f::StoreWalActionFrame::RefreshCollection { collection, .. } => Some(collection),
459            f::StoreWalActionFrame::SetAuxMetadata { bytes } => {
460                if bytes.first() == Some(&b'[') {
461                    let contracts: crate::serde_json::Value = crate::serde_json::from_slice(bytes)?;
462                    let contracts = contracts.as_array().ok_or("invalid WAL contract catalog")?;
463                    ensure(
464                        contracts.iter().all(|contract| {
465                            contract
466                                .get("name")
467                                .and_then(crate::serde_json::Value::as_str)
468                                != Some("red_config")
469                        }),
470                        "WAL references divergent legacy red_config",
471                    )?;
472                }
473                None
474            }
475        };
476        ensure(
477            name.is_none_or(|name| name != "red_config"),
478            "WAL references divergent legacy red_config",
479        )?;
480    }
481    Ok(())
482}
483
484#[cfg(test)]
485mod canonical_config_wal_tests {
486    use super::*;
487
488    #[test]
489    fn compaction_progress_is_machine_readable() {
490        let directory = tempfile::tempdir().expect("progress directory");
491        write_compaction_progress(directory.path(), "rewrite", 2, 5, 1_000)
492            .expect("write progress");
493        let progress: crate::serde_json::Value = crate::serde_json::from_slice(
494            &std::fs::read(directory.path().join("compaction.progress.json"))
495                .expect("progress file"),
496        )
497        .expect("progress JSON");
498        assert_eq!(progress["phase"].as_str(), Some("rewrite"));
499        assert_eq!(progress["completed_trees"].as_u64(), Some(2));
500        assert_eq!(progress["total_trees"].as_u64(), Some(5));
501        assert_eq!(progress["records"].as_u64(), Some(1_000));
502    }
503
504    #[test]
505    fn every_legacy_collection_action_is_rejected_before_name_normalization() {
506        use f::StoreWalActionFrame as Action;
507
508        let actions = [
509            Action::CreateCollection {
510                name: "red_config".into(),
511            },
512            Action::DropCollection {
513                name: "red_config".into(),
514            },
515            Action::UpsertEntityRecord {
516                collection: "red_config".into(),
517                record: vec![1],
518            },
519            Action::DeleteEntityRecord {
520                collection: "red_config".into(),
521                entity_id: 1,
522            },
523            Action::BulkUpsertEntityRecords {
524                collection: "red_config".into(),
525                records: vec![vec![1]],
526            },
527            Action::RefreshCollection {
528                collection: "red_config".into(),
529                records: vec![vec![1]],
530            },
531        ];
532        for action in actions {
533            let bytes = f::encode_store_wal_action_frame(&action).unwrap();
534            assert!(verify_wal_recovery_action(&bytes, false).is_ok());
535            assert!(verify_wal_recovery_action(&bytes, true)
536                .unwrap_err()
537                .to_string()
538                .contains("WAL references divergent legacy red_config"));
539        }
540        let canonical = f::encode_store_wal_action_frame(&Action::DeleteEntityRecord {
541            collection: "red.config".into(),
542            entity_id: 1,
543        })
544        .unwrap();
545        assert!(verify_wal_recovery_action(&canonical, true).is_ok());
546        let legacy_contract = f::encode_store_wal_action_frame(&Action::SetAuxMetadata {
547            bytes: br#"[{"name":"red\u005fconfig"}]"#.to_vec(),
548        })
549        .unwrap();
550        assert!(verify_wal_recovery_action(&legacy_contract, true).is_err());
551    }
552}
553
554fn verify_turbo_snapshots(
555    path: &Path,
556    store: &crate::storage::unified::UnifiedStore,
557) -> Result<bool> {
558    let snapshots = f::support_dir_for(path).join("snapshots");
559    if !snapshots.exists() {
560        return Ok(false);
561    }
562    let mut found = false;
563    for entry in std::fs::read_dir(snapshots)? {
564        let entry = entry?;
565        if entry.file_type()?.is_dir() {
566            continue;
567        }
568        found = true;
569        let path = entry.path();
570        let name = path
571            .file_stem()
572            .and_then(|name| name.to_str())
573            .ok_or("invalid turbo snapshot name")?;
574        ensure(
575            crate::runtime::vector_turbo_kind::is_turbo(store, name),
576            "snapshot has no turbo collection",
577        )?;
578        let length = entry.metadata()?.len();
579        ensure(
580            length <= 64 * 1024 * 1024,
581            "turbo snapshot exceeds recovery budget",
582        )?;
583        let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
584        File::open(&path)?.read_exact(&mut header)?;
585        let dim = u32::from_le_bytes(header[12..16].try_into()?);
586        let count = u64::from_le_bytes(header[32..40].try_into()?);
587        let record_size = u64::from(dim)
588            .checked_mul(4)
589            .and_then(|v| v.checked_add(12))
590            .ok_or("snapshot dimension overflow")?;
591        let expected = count
592            .checked_mul(record_size)
593            .and_then(|v| v.checked_add(header.len() as u64))
594            .ok_or("snapshot length overflow")?;
595        ensure(expected == length, "invalid turbo snapshot length")?;
596        let payload = f::read_turboquant_snapshot(
597            &path,
598            dim,
599            crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
600        )?;
601        for (id, vector) in payload.vectors {
602            let entity = store
603                .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
604                .ok_or("snapshot vector has no recovered entity")?;
605            let crate::storage::EntityData::Vector(data) = entity.data else {
606                return Err("snapshot points to a non-vector entity".into());
607            };
608            ensure(
609                data.dense.len() == vector.len()
610                    && data
611                        .dense
612                        .iter()
613                        .zip(&vector)
614                        .all(|(a, b)| a.to_bits() == b.to_bits()),
615                "snapshot vector differs from recovered entity",
616            )?;
617        }
618        // This verified cache predates WAL replay. Rebuild it from the durable
619        // entities instead of allowing a stale snapshot to hide recovered rows.
620        std::fs::remove_file(path)?;
621    }
622    Ok(found)
623}
624
625fn wal_has_records(path: &Path) -> Result<bool> {
626    match std::fs::metadata(path) {
627        Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
628        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
629        Err(error) => Err(error.into()),
630    }
631}
632
633fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
634    use crate::storage::wal::{WalReader, WalRecord};
635    let mut transactions = std::collections::HashMap::new();
636    let mut last_lsn = 0;
637    let mut highest_tx = 0u64;
638    let header = pager.header()?;
639    let mut target_seen = !header.checkpoint_in_progress;
640    for item in WalReader::open(path)?.iter() {
641        let (lsn, record) = item?;
642        last_lsn = lsn;
643        target_seen |= lsn == header.checkpoint_target_lsn;
644        match record {
645            WalRecord::Begin { tx_id } => {
646                highest_tx = highest_tx.max(tx_id);
647                transactions.insert(tx_id, false);
648            }
649            WalRecord::Commit { tx_id } => {
650                highest_tx = highest_tx.max(tx_id);
651                transactions.insert(tx_id, true);
652            }
653            WalRecord::Rollback { tx_id } => {
654                highest_tx = highest_tx.max(tx_id);
655                transactions.remove(&tx_id);
656            }
657            WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
658            _ => {}
659        }
660    }
661    ensure(
662        target_seen,
663        "engine WAL does not contain the interrupted checkpoint target",
664    )?;
665    // Store only the LSN of each latest committed page, not every page image.
666    let mut latest = std::collections::HashMap::new();
667    for item in WalReader::open(path)?.iter() {
668        let (lsn, record) = item?;
669        if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
670            if transactions.get(&tx_id).copied().unwrap_or(false) {
671                latest.insert(page_id, lsn);
672            }
673        }
674    }
675    ensure(
676        !header.checkpoint_in_progress || !latest.is_empty(),
677        "interrupted checkpoint has no committed page images",
678    )?;
679    if !latest.is_empty() {
680        pager.set_checkpoint_in_progress(true, last_lsn)?;
681        for item in WalReader::open(path)?.iter() {
682            let (lsn, record) = item?;
683            if let WalRecord::PageWrite { page_id, data, .. } = record {
684                if latest.get(&page_id) == Some(&lsn) {
685                    pager.write_page(page_id, Page::from_slice(&data)?)?;
686                }
687            }
688        }
689        pager.sync()?;
690        pager.complete_checkpoint(last_lsn)?;
691    }
692    Ok(highest_tx)
693}
694
695fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
696    for (old, new) in [
697        (
698            f::physical_metadata_binary_path(source),
699            f::physical_metadata_binary_path(destination),
700        ),
701        (
702            f::physical_metadata_json_path(source),
703            f::physical_metadata_json_path(destination),
704        ),
705    ] {
706        match std::fs::symlink_metadata(&old) {
707            Ok(stat) => {
708                ensure(
709                    stat.is_file() && stat.len() <= 64 * 1024 * 1024,
710                    "invalid physical metadata sidecar",
711                )?;
712                let mut input = File::open(&old)?;
713                let mut output = create_new(&new)?;
714                std::io::copy(&mut input, &mut output)?;
715                output.sync_all()?;
716                drop(output);
717                verify_file_copy(&old, &new)?;
718            }
719            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
720            Err(error) => return Err(error.into()),
721        }
722    }
723    Ok(())
724}
725
726fn compact_paged_inner(
727    source: &Path,
728    destination: &Path,
729    salvage: Option<&PagedIndexSalvageReport>,
730    lost_collections: Option<&PagedCollectionSalvageReport>,
731    retain_canonical_system_config: bool,
732) -> Result<CompactionReport> {
733    let source_path = source.canonicalize()?;
734    let parent = destination
735        .parent()
736        .filter(|parent| !parent.as_os_str().is_empty())
737        .unwrap_or_else(|| Path::new("."))
738        .canonicalize()?;
739    for source_directory in [
740        f::support_dir_for(&source_path),
741        source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
742    ] {
743        if source_directory.exists() {
744            ensure(
745                !parent.starts_with(source_directory.canonicalize()?),
746                "destination is inside source storage",
747            )?;
748        }
749    }
750    let source = Arc::new(Source::open(&source_path)?);
751    let mut catalog = Catalog::read(&source)?;
752    if retain_canonical_system_config {
753        catalog.retain_canonical_system_config()?;
754    }
755    ensure(
756        !source.header.requires_disk_residency() || catalog.resume.is_some(),
757        "prepared disk-resident file has no resume catalog",
758    )?;
759    if let Some(salvage) = salvage {
760        let resume = catalog
761            .resume
762            .as_mut()
763            .ok_or("index recovery requires a resume catalog")?;
764        for finding in &salvage.indexes {
765            let state = resume
766                .collections
767                .get_mut(&finding.collection)
768                .ok_or("index collection missing from resume")?;
769            let index = state
770                .secondary_indices
771                .remove(&finding.index)
772                .ok_or("index missing from resume")?;
773            ensure(
774                index.root_page == finding.root_page,
775                "index root changed during salvage",
776            )?;
777        }
778    }
779    if let Some(losses) = lost_collections {
780        ensure(
781            catalog.resume.is_none() && !catalog.has_cross_references(),
782            "collection recovery requires a reference-free legacy catalog",
783        )?;
784        for loss in &losses.collections {
785            let root = catalog
786                .roots
787                .iter_mut()
788                .find(|root| root.collection == loss.collection)
789                .ok_or("collection missing from catalog")?;
790            ensure(
791                root.root_page == loss.root_page,
792                "collection root changed during salvage",
793            )?;
794            if loss.skipped_pages == [loss.root_page] {
795                root.root_page = 0;
796            }
797        }
798    }
799    let name = source_path
800        .file_name()
801        .ok_or("source filename is missing")?;
802    // Exclusive creation prevents clobbering files, symlinks and other runs.
803    std::fs::create_dir(destination)?;
804    #[cfg(unix)]
805    {
806        use std::os::unix::fs::PermissionsExt;
807        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
808    }
809    let total_trees = catalog.roots.len()
810        + catalog.resume.as_ref().map_or(0, |resume| {
811            resume
812                .collections
813                .values()
814                .map(|state| {
815                    usize::from(state.key_index_root.is_some()) + state.secondary_indices.len()
816                })
817                .sum::<usize>()
818        });
819    let mut completed_trees = 0usize;
820    write_compaction_progress(destination, "rewrite", completed_trees, total_trees, 0)?;
821    let output = destination.join(name);
822    let temporary = destination.join(".compacting.rdb");
823    ensure(output != temporary, "reserved compaction source filename")?;
824    let target = Arc::new(Pager::open(
825        &temporary,
826        PagerConfig {
827            cache_size: 1024,
828            disk_residency: source.header.requires_disk_residency(),
829            ..PagerConfig::default()
830        },
831    )?);
832    let mut roots = BTreeMap::new();
833    if source.header.required_capabilities & f::file_format::FILE_CAPABILITY_TREE_PROOFS != 0 {
834        target.fence_required_capabilities(
835            f::file_format::FILE_CAPABILITY_TREE_PROOFS
836                | f::file_format::FILE_CAPABILITY_DWB_PUBLICATION,
837        )?;
838    }
839    let mut tree_proofs = f::page_proof::CatalogProofs::new();
840    let mut recovered_trees = BTreeMap::new();
841    let mut records = 0;
842    let mut repaired_leaf_backlinks = 0;
843    for collection in &catalog.roots {
844        let loss = lost_collections.and_then(|report| {
845            report
846                .collections
847                .iter()
848                .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
849        });
850        let count = if let Some(loss) = loss {
851            let (count, hash) =
852                rewrite_recoverable_tree(&source, &target, loss, &mut roots, &mut tree_proofs)?;
853            recovered_trees.insert(loss.root_page, (count, hash));
854            count
855        } else {
856            rewrite_tree(
857                &source,
858                &target,
859                collection.root_page,
860                &mut roots,
861                &mut repaired_leaf_backlinks,
862                &mut tree_proofs,
863            )?
864        };
865        if let Some(resume) = &catalog.resume {
866            ensure(
867                resume.collections[&collection.collection].entities == count,
868                "collection count disagrees with resume",
869            )?;
870        }
871        records += count;
872        completed_trees += 1;
873        write_compaction_progress(
874            destination,
875            "rewrite",
876            completed_trees,
877            total_trees,
878            records,
879        )?;
880    }
881    if let Some(resume) = &catalog.resume {
882        for state in resume.collections.values() {
883            if let Some(root) = state.key_index_root {
884                let count = rewrite_tree(
885                    &source,
886                    &target,
887                    root,
888                    &mut roots,
889                    &mut repaired_leaf_backlinks,
890                    &mut tree_proofs,
891                )?;
892                records += count;
893                completed_trees += 1;
894                write_compaction_progress(
895                    destination,
896                    "rewrite",
897                    completed_trees,
898                    total_trees,
899                    records,
900                )?;
901            }
902            for index in state.secondary_indices.values() {
903                let count = rewrite_tree(
904                    &source,
905                    &target,
906                    index.root_page,
907                    &mut roots,
908                    &mut repaired_leaf_backlinks,
909                    &mut tree_proofs,
910                )?;
911                records += count;
912                completed_trees += 1;
913                write_compaction_progress(
914                    destination,
915                    "rewrite",
916                    completed_trees,
917                    total_trees,
918                    records,
919                )?;
920            }
921        }
922    }
923    write_compaction_progress(destination, "assets", completed_trees, total_trees, records)?;
924    let mut assets = assets::Assets::new(&source, &target);
925    assets.vault()?;
926    let physical = assets.physical()?;
927    let internal = internal_metadata::read(&source, &catalog)?;
928    let rewritten_document = assets.metadata(
929        &source_path,
930        Some(&output),
931        internal.as_ref().map(|record| record.document.as_str()),
932    )?;
933    let mut rewritten_metadata = None;
934    if let (Some(record), Some(document)) = (internal, rewritten_document) {
935        let old_root = record.root;
936        let key = record.key.clone();
937        let value = record.rewrite(document)?;
938        let previous_root = roots[&old_root];
939        let tree = match tree_proofs.get(&previous_root).copied() {
940            Some(proof) => BTree::with_verified_root(Arc::clone(&target), previous_root, proof)?,
941            None => BTree::with_root(Arc::clone(&target), previous_root),
942        };
943        tree.upsert(&key, &value)?;
944        tree_proofs.remove(&previous_root);
945        remember_output_proof(&tree, &mut tree_proofs)?;
946        roots.insert(old_root, tree.root_page_id());
947        rewritten_metadata = Some((old_root, key, value));
948    }
949    catalog.claim_retired(&source)?;
950    if salvage.is_some() || lost_collections.is_some() {
951        if let Some(salvage) = salvage {
952            for index in &salvage.indexes {
953                if index.root_page != 0 {
954                    if source.claim_published_tree(index.root_page)? {
955                        continue;
956                    }
957                    match source.raw_page(index.root_page, true) {
958                        Ok(_) => {
959                            let tree = source.tree(index.root_page)?;
960                            let skipped = tree.visit_recoverable_leaves(
961                                |id| {
962                                    source.claim(id).map_err(|error| {
963                                        reddb_engine::storage::btree::BTreeError::Corrupted(
964                                            error.to_string(),
965                                        )
966                                    })
967                                },
968                                |_, _| Ok(()),
969                            )?;
970                            ensure(
971                                !skipped.is_empty(),
972                                "index damage is not a verified leaf checksum failure",
973                            )?;
974                        }
975                        Err(error) => {
976                            ensure(
977                                matches!(
978                                    error.downcast_ref::<reddb_engine::storage::page::PageError>(),
979                                    Some(
980                                        reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
981                                    )
982                                ),
983                                "index root failure is not a checksum mismatch",
984                            )?;
985                            ensure(
986                                source.raw_page(index.root_page, false)?.page_type()?
987                                    == PageType::BTreeLeaf,
988                                "damaged interior index root cannot be recovered",
989                            )?;
990                            source.claim(index.root_page)?;
991                        }
992                    }
993                }
994            }
995        }
996        if let Some(losses) = lost_collections {
997            for collection in &losses.collections {
998                if collection.skipped_pages == [collection.root_page] {
999                    if !source.claim_published_tree(collection.root_page)? {
1000                        source.claim(collection.root_page)?;
1001                    }
1002                }
1003            }
1004        }
1005        ensure(
1006            source.unclaimed_pages() == 0,
1007            "unclassified source pages require separate recovery verification",
1008        )?;
1009    }
1010    // The unpublished output has no readers or prior authoritative generation.
1011    let (pending, _) = target.deferred_reclamation_pages()?;
1012    target.complete_deferred_publication(&[], &pending)?;
1013    let enabled =
1014        target.header()?.required_capabilities & f::file_format::FILE_CAPABILITY_TREE_PROOFS != 0;
1015    catalog::write_catalog(
1016        &target,
1017        &catalog.encode_with_proofs(&roots, enabled.then_some(&tree_proofs))?,
1018    )?;
1019    target.update_physical_header(physical)?;
1020    let capabilities = (source.header.required_capabilities
1021        & !f::file_format::FILE_CAPABILITY_RECOVERY_READ_ONLY)
1022        | if source.header.requires_disk_residency() {
1023            f::FILE_CAPABILITY_DISK_RESIDENCY
1024        } else {
1025            0
1026        };
1027    if capabilities != 0 {
1028        target.fence_required_capabilities(capabilities)?;
1029    }
1030    target.fence_file_format_version(source.header.version.max(target.header()?.version))?;
1031    target.sync()?;
1032    // Preserve source schema/LSN, but retain the newly allocated page boundary.
1033    for _ in 0..2 {
1034        target.commit_superblock(|slot| {
1035            let mut header = f::decode_published_database_header(slot).map_err(|error| {
1036                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
1037            })?;
1038            header.schema_version = source.header.schema_version;
1039            header.checkpoint_lsn = source.header.checkpoint_lsn;
1040            f::encode_database_header(slot, &header).map_err(|error| {
1041                reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
1042            })
1043        })?;
1044    }
1045    if lost_collections.is_some() || source.header.is_recovery_read_only() {
1046        // Both copies must be fenced before the recovered filename is published.
1047        // Old engines reject this newer version; this engine admits it read-only.
1048        target.fence_required_capabilities(f::file_format::FILE_CAPABILITY_RECOVERY_READ_ONLY)?;
1049    }
1050    let assets_verification = assets.into_verification();
1051    drop(target);
1052    write_compaction_progress(destination, "verify", completed_trees, total_trees, records)?;
1053    let reopened = Arc::new(Source::open(&temporary)?);
1054    if salvage.is_some() || lost_collections.is_some() {
1055        salvage::verify_vault(&temporary, &reopened)?;
1056    }
1057    ensure(
1058        reopened.header.physical == physical,
1059        "rewritten physical header failed parity",
1060    )?;
1061    let rewritten_catalog = Catalog::read(&reopened)?;
1062    ensure(
1063        rewritten_catalog.encode(&identity_roots(&roots))?
1064            == catalog.encode_with_proofs(&roots, enabled.then_some(&tree_proofs))?,
1065        "rewritten catalog differs from source",
1066    )?;
1067    for (&old, &new) in &roots {
1068        if let Some((count, hash)) = recovered_trees.get(&old) {
1069            verify_recovered_tree(&reopened, new, *count, *hash)?;
1070        } else {
1071            verify_tree(
1072                &source,
1073                &reopened,
1074                old,
1075                new,
1076                rewritten_metadata.as_ref().filter(|record| record.0 == old),
1077            )?;
1078        }
1079    }
1080    for (id, expected, checksum) in assets_verification {
1081        let page = reopened.raw_page(id, checksum)?;
1082        if id != 2 {
1083            reopened.claim(id)?;
1084        }
1085        ensure(
1086            blake3::hash(page.content()) == expected,
1087            "native asset failed readback parity",
1088        )?;
1089    }
1090    let report = CompactionReport {
1091        retired_boot_system_wal_actions: 0,
1092        reconciled_system_collections: 0,
1093        imported_system_records: 0,
1094        source_bytes: source.bytes,
1095        destination_bytes: reopened.bytes,
1096        source_allocated_bytes: allocated_bytes(&source_path)?,
1097        destination_allocated_bytes: allocated_bytes(&temporary)?,
1098        records,
1099        trees: roots.len(),
1100        repaired_leaf_backlinks,
1101    };
1102    drop(reopened);
1103    copy_sidecars(&source_path, &output, false)?;
1104    if retain_canonical_system_config {
1105        let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
1106        if operational_path.exists() {
1107            crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
1108                .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
1109        }
1110    }
1111    write_new(
1112        &destination.join("compaction.json"),
1113        report.machine_json().as_bytes(),
1114    )?;
1115    if let Some(salvage) = salvage {
1116        write_new(
1117            &destination.join("salvage.json"),
1118            salvage.machine_json().as_bytes(),
1119        )?;
1120    }
1121    if let Some(losses) = lost_collections {
1122        write_new(
1123            &destination.join("salvage.json"),
1124            losses.machine_json().as_bytes(),
1125        )?;
1126    }
1127    sync_directory(destination)?;
1128    write_compaction_progress(
1129        destination,
1130        "publish",
1131        completed_trees,
1132        total_trees,
1133        records,
1134    )?;
1135    // hard_link is create-new even if an unexpected file appears during work.
1136    std::fs::hard_link(&temporary, &output)?;
1137    sync_directory(destination)?;
1138    std::fs::remove_file(&temporary)?;
1139    sync_directory(destination)?;
1140    if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
1141        sync_directory(parent)?;
1142    }
1143    write_compaction_progress(
1144        destination,
1145        "complete",
1146        completed_trees,
1147        total_trees,
1148        records,
1149    )?;
1150    sync_directory(destination)?;
1151    std::fs::remove_file(destination.join("compaction.progress.json"))?;
1152    sync_directory(destination)?;
1153    Ok(report)
1154}
1155
1156fn write_compaction_progress(
1157    destination: &Path,
1158    phase: &str,
1159    completed_trees: usize,
1160    total_trees: usize,
1161    records: u64,
1162) -> Result<()> {
1163    let mut file = OpenOptions::new()
1164        .create(true)
1165        .write(true)
1166        .truncate(true)
1167        .open(destination.join("compaction.progress.json"))?;
1168    write!(
1169        file,
1170        "{{\"phase\":\"{phase}\",\"completed_trees\":{completed_trees},\"total_trees\":{total_trees},\"records\":{records}}}"
1171    )?;
1172    file.flush()?;
1173    Ok(())
1174}
1175fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
1176    roots.values().map(|root| (*root, *root)).collect()
1177}
1178
1179fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
1180    hasher.update(&(key.len() as u64).to_le_bytes());
1181    hasher.update(key);
1182    hasher.update(&(value.len() as u64).to_le_bytes());
1183    hasher.update(value);
1184}
1185
1186fn new_output_tree(target: &Arc<Pager>) -> Result<BTree<Pager>> {
1187    if target.header()?.required_capabilities & f::file_format::FILE_CAPABILITY_TREE_PROOFS != 0 {
1188        Ok(BTree::new_verified(Arc::clone(target))?)
1189    } else {
1190        Ok(BTree::new(Arc::clone(target)))
1191    }
1192}
1193
1194fn remember_output_proof(
1195    tree: &BTree<Pager>,
1196    proofs: &mut f::page_proof::CatalogProofs,
1197) -> Result<()> {
1198    if let Some((root, proof)) = tree.seal_page_proofs()? {
1199        if root != 0 {
1200            ensure(
1201                proofs.insert(root, proof).is_none(),
1202                "duplicate output tree proof",
1203            )?;
1204        }
1205    }
1206    Ok(())
1207}
1208
1209fn rewrite_recoverable_tree(
1210    source: &Arc<Source>,
1211    target: &Arc<Pager>,
1212    loss: &PagedCollectionLoss,
1213    roots: &mut BTreeMap<u32, u32>,
1214    proofs: &mut f::page_proof::CatalogProofs,
1215) -> Result<(u64, blake3::Hash)> {
1216    ensure(
1217        !roots.contains_key(&loss.root_page),
1218        "duplicate live tree owner",
1219    )?;
1220    let original = source.tree(loss.root_page)?;
1221    let destination = new_output_tree(target)?;
1222    let mut hash = blake3::Hasher::new();
1223    let mut count = 0u64;
1224    let mut batch = Vec::new();
1225    let mut size = 0usize;
1226    let skipped = original.visit_recoverable_leaves(
1227        |id| {
1228            source.claim(id).map_err(|error| {
1229                reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
1230            })
1231        },
1232        |key, value| {
1233            hash_record(&mut hash, &key, &value);
1234            size += key.len() + value.len();
1235            batch.push((key, value));
1236            count += 1;
1237            if size >= 1024 * 1024 || batch.len() >= 512 {
1238                destination.bulk_insert_sorted(&batch)?;
1239                batch.clear();
1240                size = 0;
1241            }
1242            Ok(())
1243        },
1244    )?;
1245    ensure(
1246        skipped == loss.skipped_pages,
1247        "damaged pages changed during recovery",
1248    )?;
1249    destination.bulk_insert_sorted(&batch)?;
1250    remember_output_proof(&destination, proofs)?;
1251    roots.insert(loss.root_page, destination.root_page_id());
1252    Ok((count, hash.finalize()))
1253}
1254
1255fn verify_recovered_tree(
1256    source: &Arc<Source>,
1257    root: u32,
1258    count: u64,
1259    expected: blake3::Hash,
1260) -> Result<()> {
1261    let tree = source.tree(root)?;
1262    let (owned, _) = inventory_tree(source, &tree, false)?;
1263    ensure(owned == count, "recovered tree record count differs")?;
1264    let mut cursor = tree.cursor_first()?;
1265    let mut hash = blake3::Hasher::new();
1266    let mut actual = 0u64;
1267    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1268        hash_record(&mut hash, &key, &value);
1269        actual += 1;
1270        ensure(actual <= count, "recovered tree has extra records")?;
1271    }
1272    ensure(
1273        actual == count && hash.finalize() == expected,
1274        "recovered tree failed record parity",
1275    )
1276}
1277
1278fn rewrite_tree(
1279    source: &Arc<Source>,
1280    target: &Arc<Pager>,
1281    root: u32,
1282    roots: &mut BTreeMap<u32, u32>,
1283    repaired_leaf_backlinks: &mut u64,
1284    proofs: &mut f::page_proof::CatalogProofs,
1285) -> Result<u64> {
1286    if root == 0 {
1287        return Ok(0);
1288    }
1289    ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
1290    let tree = source.tree(root)?;
1291    let (expected, repairs) = inventory_tree(source, &tree, true)?;
1292    *repaired_leaf_backlinks += repairs;
1293    let destination = new_output_tree(target)?;
1294    let mut cursor = tree.cursor_first()?;
1295    let mut count = 0;
1296    let mut previous: Option<Vec<u8>> = None;
1297    let mut batch = Vec::new();
1298    let mut bytes = 0;
1299    while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
1300        ensure(
1301            previous.as_ref().is_none_or(|old| old < &key),
1302            "source tree keys are not strictly ordered",
1303        )?;
1304        previous = Some(key.clone());
1305        bytes += key.len() + value.len();
1306        batch.push((key, value));
1307        count += 1;
1308        ensure(
1309            count <= expected,
1310            "tree cursor exceeds structural inventory",
1311        )?;
1312        if bytes >= 1024 * 1024 || batch.len() >= 512 {
1313            destination.bulk_insert_sorted(&batch)?;
1314            batch.clear();
1315            bytes = 0;
1316        }
1317    }
1318    destination.bulk_insert_sorted(&batch)?;
1319    remember_output_proof(&destination, proofs)?;
1320    ensure(count == expected, "tree cursor omits owned records")?;
1321    roots.insert(root, destination.root_page_id());
1322    Ok(count)
1323}
1324// Interior ownership and forward traversal must agree exactly. Old split code
1325// omitted the successor backlink update; that redundant edge can be rebuilt
1326// without guessing any ownership or key/value. Never repair forward omissions.
1327fn inventory_tree(
1328    source: &Source,
1329    tree: &BTree<Source>,
1330    allow_backlinks: bool,
1331) -> Result<(u64, u64)> {
1332    let pages = tree.retired_pages()?;
1333    let mut leaves = Vec::new();
1334    let mut expected = 0u64;
1335    for id in pages {
1336        source.claim(id)?;
1337        let page = source.raw_page(id, true)?;
1338        if page.page_type()? == PageType::BTreeLeaf {
1339            leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
1340            expected += u64::from(page.cell_count());
1341        }
1342    }
1343    // Ownership traversal is right-to-left. A cursor follows the independent
1344    // leaf chain; prove it visits exactly the structurally owned leaves.
1345    leaves.reverse();
1346    let mut repaired = 0;
1347    for (index, &(id, previous, next)) in leaves.iter().enumerate() {
1348        ensure(
1349            next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
1350            &format!(
1351                "broken forward leaf chain at page {id}, root {}",
1352                tree.root_page_id()
1353            ),
1354        )?;
1355        if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
1356            ensure(
1357                allow_backlinks,
1358                &format!("broken backward leaf chain at page {id}"),
1359            )?;
1360            repaired += 1;
1361        }
1362    }
1363    Ok((expected, repaired))
1364}
1365fn verify_tree(
1366    source: &Arc<Source>,
1367    destination: &Arc<Source>,
1368    old: u32,
1369    new: u32,
1370    rewritten: Option<&(u32, Vec<u8>, Vec<u8>)>,
1371) -> Result<()> {
1372    let old = source.tree(old)?;
1373    let new = destination.tree(new)?;
1374    inventory_tree(destination, &new, false)?;
1375    let mut left = old.cursor_first()?;
1376    let mut right = new.cursor_first()?;
1377    loop {
1378        let mut expected = left.next_with_budget(32 * 1024 * 1024)?;
1379        if let (Some((key, value)), Some((_, rewritten_key, rewritten_value))) =
1380            (&mut expected, rewritten)
1381        {
1382            if key == rewritten_key {
1383                *value = rewritten_value.clone();
1384            }
1385        }
1386        let actual = right.next_with_budget(32 * 1024 * 1024)?;
1387        ensure(
1388            expected == actual,
1389            "compacted tree failed complete key/value parity",
1390        )?;
1391        if expected.is_none() {
1392            return Ok(());
1393        }
1394    }
1395}
1396fn copy_sidecars(source: &Path, destination: &Path, recovery: bool) -> Result<()> {
1397    // These files contain logical records, not physical page addresses. Empty
1398    // checkpointed WALs also preserve their version exactly.
1399    for (old, new) in [
1400        (
1401            f::unified_wal_path(source),
1402            f::unified_wal_path(destination),
1403        ),
1404        (f::engine_wal_path(source), f::engine_wal_path(destination)),
1405        (
1406            f::logical_wal_path(source),
1407            f::logical_wal_path(destination),
1408        ),
1409        (
1410            f::legacy_logical_slots_path(source),
1411            f::legacy_logical_slots_path(destination),
1412        ),
1413    ] {
1414        match File::open(&old) {
1415            Ok(_) => copy_file_verified(&old, &new)?,
1416            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1417            Err(error) => return Err(error.into()),
1418        }
1419    }
1420    copy_audit_log_family(source, destination)?;
1421    let support = f::support_dir_for(source);
1422    if support.exists() {
1423        if recovery {
1424            validate_recovery_support(&support, 0, false)?;
1425        } else {
1426            validate_empty_directory(&support, 0)?;
1427        }
1428        copy_directory(&support, &f::support_dir_for(destination), 0)?;
1429    }
1430    let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
1431    let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
1432    if old.exists() {
1433        f::OperationalManifest::for_db_path(source).verify_owned_for_copy()?;
1434        copy_directory(&old, &new, 0)?;
1435    }
1436    Ok(())
1437}
1438
1439fn detach_recovery_audit_logs(source: &Path, stage: &Path) -> Result<Option<PathBuf>> {
1440    let family = audit_log_family(source)?;
1441    if family.is_empty() {
1442        return Ok(None);
1443    }
1444    let preserved = stage.join("preserved-audit");
1445    std::fs::create_dir(&preserved)?;
1446    for audit in family {
1447        let name = audit.file_name().ok_or("audit log filename is missing")?;
1448        std::fs::rename(&audit, preserved.join(name))?;
1449    }
1450    sync_directory(&preserved)?;
1451    sync_directory(stage)?;
1452    Ok(Some(preserved))
1453}
1454
1455fn restore_recovery_audit_logs(preserved: &Path, destination: &Path) -> Result<()> {
1456    let parent = destination
1457        .parent()
1458        .ok_or("destination parent is missing")?;
1459    for entry in std::fs::read_dir(preserved)? {
1460        let entry = entry?;
1461        ensure(
1462            entry.file_type()?.is_file(),
1463            "preserved audit log must be a regular file",
1464        )?;
1465        copy_file_verified(&entry.path(), &parent.join(entry.file_name()))?;
1466    }
1467    sync_directory(parent)
1468}
1469
1470fn copy_file_verified(source: &Path, destination: &Path) -> Result<()> {
1471    let mut input = File::open(source)?;
1472    let mut output = create_new(destination)?;
1473    std::io::copy(&mut input, &mut output)?;
1474    output.sync_all()?;
1475    verify_file_copy(source, destination)
1476}
1477
1478fn audit_log_family(data_path: &Path) -> Result<Vec<PathBuf>> {
1479    let active = f::legacy_audit_log_path(data_path);
1480    let parent = active.parent().ok_or("audit log parent is missing")?;
1481    let mut rotated = Vec::new();
1482    for entry in std::fs::read_dir(parent)? {
1483        let entry = entry?;
1484        let name = entry.file_name();
1485        let Some(name) = name.to_str() else {
1486            continue;
1487        };
1488        let Some(timestamp) = f::parse_audit_log_rotated_timestamp(&active, name) else {
1489            continue;
1490        };
1491        ensure(
1492            entry.file_type()?.is_file(),
1493            "rotated audit log must be a regular file",
1494        )?;
1495        rotated.push((timestamp, entry.path()));
1496    }
1497    rotated.sort_by_key(|(timestamp, _)| *timestamp);
1498    for pair in rotated.windows(2) {
1499        ensure(
1500            pair[0].0 != pair[1].0,
1501            "duplicate rotated audit log timestamp",
1502        )?;
1503    }
1504    let mut family: Vec<PathBuf> = rotated.into_iter().map(|(_, path)| path).collect();
1505    match std::fs::symlink_metadata(&active) {
1506        Ok(metadata) => {
1507            ensure(metadata.is_file(), "audit log must be a regular file")?;
1508            family.push(active);
1509        }
1510        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1511        Err(error) => return Err(error.into()),
1512    }
1513    Ok(family)
1514}
1515
1516fn copy_audit_log_family(source: &Path, destination: &Path) -> Result<()> {
1517    let destination_parent = destination
1518        .parent()
1519        .ok_or("audit log destination parent is missing")?;
1520    for old in audit_log_family(source)? {
1521        let name = old.file_name().ok_or("audit log filename is missing")?;
1522        copy_file_verified(&old, &destination_parent.join(name))?;
1523    }
1524    Ok(())
1525}
1526
1527pub(super) fn verify_audit_log_family(data_path: &Path) -> Result<()> {
1528    let mut previous = None;
1529    let mut has_events = false;
1530    for path in audit_log_family(data_path)? {
1531        let input = File::open(&path)?;
1532        if path
1533            .extension()
1534            .is_some_and(|extension| extension == f::layout::AUDIT_LOG_ROTATED_COMPRESSED_EXTENSION)
1535        {
1536            let decoder = zstd::stream::read::Decoder::new(input)?;
1537            (previous, has_events) = verify_audit_log_chain(decoder, previous, has_events)?;
1538        } else {
1539            (previous, has_events) = verify_audit_log_chain(input, previous, has_events)?;
1540        }
1541    }
1542    Ok(())
1543}
1544
1545fn verify_audit_log_chain(
1546    input: impl Read,
1547    mut previous: Option<String>,
1548    mut has_events: bool,
1549) -> Result<(Option<String>, bool)> {
1550    const MAX_EVENT_BYTES: usize = 1024 * 1024;
1551    let mut reader = BufReader::new(input);
1552    let mut line = Vec::new();
1553    loop {
1554        line.clear();
1555        loop {
1556            let buffer = reader.fill_buf()?;
1557            if buffer.is_empty() {
1558                if line.is_empty() {
1559                    return Ok((previous, has_events));
1560                }
1561                break;
1562            }
1563            let count = buffer
1564                .iter()
1565                .position(|byte| *byte == b'\n')
1566                .map_or(buffer.len(), |position| position + 1);
1567            ensure(
1568                line.len().saturating_add(count) <= MAX_EVENT_BYTES,
1569                "audit log event exceeds recovery verification budget",
1570            )?;
1571            line.extend_from_slice(&buffer[..count]);
1572            let ended = buffer[count - 1] == b'\n';
1573            reader.consume(count);
1574            if ended {
1575                break;
1576            }
1577        }
1578        if line.last() == Some(&b'\n') {
1579            line.pop();
1580        }
1581        if line.last() == Some(&b'\r') {
1582            line.pop();
1583        }
1584        ensure(!line.is_empty(), "audit log contains an empty event")?;
1585        let value: crate::serde_json::Value = crate::serde_json::from_slice(&line)?;
1586        ensure(
1587            value.as_object().is_some(),
1588            "audit log event must be a JSON object",
1589        )?;
1590        let declared = match value.get("prev_hash") {
1591            Some(value) => Some(
1592                value
1593                    .as_str()
1594                    .ok_or("audit log prev_hash must be a string")?,
1595            ),
1596            None => None,
1597        };
1598        if has_events {
1599            ensure(
1600                declared == previous.as_deref(),
1601                "audit log hash chain verification failed",
1602            )?;
1603        } else if let Some(anchor) = declared {
1604            ensure(
1605                anchor.len() == 64 && anchor.bytes().all(|byte| byte.is_ascii_hexdigit()),
1606                "audit log initial prev_hash is invalid",
1607            )?;
1608        }
1609        previous = Some(crate::utils::to_hex(&crate::crypto::sha256(&line)));
1610        has_events = true;
1611    }
1612}
1613
1614fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
1615    ensure(depth < 32, "operational directory nesting exceeds limit")?;
1616    ensure(
1617        std::fs::symlink_metadata(source)?.is_dir(),
1618        "operational directory must not be a symlink",
1619    )?;
1620    std::fs::create_dir(destination)?;
1621    for entry in std::fs::read_dir(source)? {
1622        let entry = entry?;
1623        let kind = entry.file_type()?;
1624        let output = destination.join(entry.file_name());
1625        if kind.is_dir() {
1626            copy_directory(&entry.path(), &output, depth + 1)?;
1627        } else {
1628            ensure(
1629                kind.is_file(),
1630                "operational directory contains a symlink or special file",
1631            )?;
1632            let mut input = File::open(entry.path())?;
1633            let mut target = create_new(&output)?;
1634            std::io::copy(&mut input, &mut target)?;
1635            target.sync_all()?;
1636            verify_file_copy(&entry.path(), &output)?;
1637        }
1638    }
1639    sync_directory(destination)
1640}
1641fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
1642    ensure(depth < 32, "support directory nesting exceeds limit")?;
1643    ensure(
1644        std::fs::symlink_metadata(path)?.is_dir(),
1645        "support directory must not be a symlink",
1646    )?;
1647    for entry in std::fs::read_dir(path)? {
1648        let entry = entry?;
1649        ensure(
1650            entry.file_type()?.is_dir(),
1651            "non-empty support layout requires separate compaction qualification",
1652        )?;
1653        validate_empty_directory(&entry.path(), depth + 1)?;
1654    }
1655    Ok(())
1656}
1657fn validate_recovery_support(path: &Path, depth: usize, snapshots: bool) -> Result<()> {
1658    ensure(depth < 32, "support directory nesting exceeds limit")?;
1659    ensure(
1660        std::fs::symlink_metadata(path)?.is_dir(),
1661        "support directory must not be a symlink",
1662    )?;
1663    for entry in std::fs::read_dir(path)? {
1664        let entry = entry?;
1665        let kind = entry.file_type()?;
1666        if kind.is_dir() {
1667            validate_recovery_support(
1668                &entry.path(),
1669                depth + 1,
1670                depth == 0 && entry.file_name() == "snapshots",
1671            )?;
1672        } else {
1673            ensure(
1674                snapshots
1675                    && kind.is_file()
1676                    && entry.path().extension().is_some_and(|ext| ext == "tv"),
1677                "unsupported recovery support file",
1678            )?;
1679        }
1680    }
1681    Ok(())
1682}
1683
1684fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1685    ensure(
1686        hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1687        "sidecar failed complete readback parity",
1688    )
1689}
1690fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1691    let mut hash = blake3::Hasher::new();
1692    let mut buffer = [0; 64 * 1024];
1693    loop {
1694        let count = input.read(&mut buffer)?;
1695        if count == 0 {
1696            break;
1697        }
1698        hash.update(&buffer[..count]);
1699    }
1700    Ok(hash.finalize())
1701}
1702fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1703    #[cfg(unix)]
1704    {
1705        use std::os::unix::fs::MetadataExt;
1706        Ok(Some(
1707            std::fs::metadata(path)?
1708                .blocks()
1709                .checked_mul(512)
1710                .ok_or("allocated byte count overflow")?,
1711        ))
1712    }
1713    #[cfg(not(unix))]
1714    {
1715        let _ = path;
1716        Ok(None)
1717    }
1718}
1719fn create_new(path: &Path) -> std::io::Result<File> {
1720    let mut options = OpenOptions::new();
1721    options.write(true).create_new(true);
1722    #[cfg(unix)]
1723    {
1724        use std::os::unix::fs::OpenOptionsExt;
1725        options.mode(0o600);
1726    }
1727    options.open(path)
1728}
1729fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1730    let mut file = create_new(path)?;
1731    file.write_all(bytes)?;
1732    file.sync_all()?;
1733    Ok(())
1734}
1735fn sync_directory(_path: &Path) -> Result<()> {
1736    #[cfg(unix)]
1737    File::open(_path)?.sync_all()?;
1738    Ok(())
1739}
1740fn ensure(condition: bool, message: &str) -> Result<()> {
1741    if condition {
1742        Ok(())
1743    } else {
1744        Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1745    }
1746}
1747fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1748    Ok(u32::from_le_bytes(
1749        bytes
1750            .get(offset..offset + 4)
1751            .ok_or("truncated native integer")?
1752            .try_into()?,
1753    ))
1754}