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