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