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.header.version
218            >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
219            .then_some(64 * 1024 * 1024),
220        ..Default::default()
221    };
222    if pending_engine {
223        let pager = Pager::open(
224            &staged_source,
225            PagerConfig {
226                disk_residency: config.resident_memory_bytes_max.is_some(),
227                ..PagerConfig::default()
228            },
229        )?;
230        let wal = f::engine_wal_path(&staged_source);
231        recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
232        let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
233        writer.truncate()?;
234        writer.sync()?;
235    }
236    if retain_canonical_system_config && pending_unified && guard.pending_double_write {
237        // Restore the copy's DWB before trusting catalog roots or deletion IDs.
238        // The preserved source and its pending frame remain untouched.
239        {
240            let pager = Pager::open(
241                &staged_source,
242                PagerConfig {
243                    disk_residency: config.resident_memory_bytes_max.is_some(),
244                    ..PagerConfig::default()
245                },
246            )?;
247            pager.sync()?;
248        }
249        let checkpoint = Arc::new(Source::open_for_wal_recovery(&staged_source)?);
250        let staged_wal = f::unified_wal_path(&staged_source);
251        boot_wal =
252            boot_system_wal::BootSystemWalPlan::prepare(&checkpoint, &staged_wal, pending_engine)?;
253        verify_wal_recovery_records(&staged_wal, true, &boot_wal)?;
254    }
255    let catalog_plan = if retain_canonical_system_config {
256        catalog_aliases::CanonicalCatalogPlan::prepare(
257            &staged_source,
258            &f::unified_wal_path(&staged_source),
259        )?
260    } else {
261        catalog_aliases::CanonicalCatalogPlan::default()
262    };
263    let probabilistic_deltas;
264    let mut has_vector_cache = false;
265    {
266        let store = crate::storage::unified::UnifiedStore::open_with_config_for_recovery(
267            &staged_source,
268            config,
269            retain_canonical_system_config,
270            Some(&boot_wal),
271            Some(&catalog_plan),
272        )?;
273        boot_wal.preserve_identity_floors(&store)?;
274        catalog_plan.restore(&store)?;
275        if pending_engine || guard.pending_double_write {
276            store.reconcile_physical_recovery()?;
277        }
278        if pending_unified || pending_engine || guard.pending_double_write {
279            // Replayed physical pages or logical overlays may contain newer
280            // identifiers than the old catalog. Capture their floors before
281            // checkpoint turns the overlay into an immutable base.
282            let mut high = store
283                .startup_xid_high_water_mark()
284                .unwrap_or(0)
285                .max(recovered_engine_xid);
286            for name in store.list_collections() {
287                let manager = store
288                    .get_collection(&name)
289                    .ok_or("recovered collection disappeared")?;
290                let mut max_entity = 0u64;
291                let mut max_row = 0u64;
292                let mut max_sequence = 0u64;
293                let mut has_entity = false;
294                manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
295                    has_entity = true;
296                    high = high.max(entity.xmin).max(entity.xmax);
297                    max_entity = max_entity.max(entity.id.raw());
298                    max_sequence = max_sequence.max(entity.sequence_id);
299                    if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
300                        max_row = max_row.max(*row_id);
301                    }
302                    true
303                })?;
304                if !has_entity {
305                    continue;
306                }
307                let entity_next = max_entity
308                    .checked_add(1)
309                    .ok_or("recovered entity IDs are exhausted")?;
310                let row_next = max_row
311                    .checked_add(1)
312                    .ok_or("recovered row IDs are exhausted")?;
313                let sequence_next = max_sequence
314                    .checked_add(1)
315                    .ok_or("recovered sequence IDs are exhausted")?;
316                manager.restore_identity_counters(entity_next, row_next);
317                manager.restore_sequence_id_next(sequence_next);
318                store.register_entity_id(crate::storage::EntityId::new(max_entity));
319            }
320            ensure(
321                high < u64::MAX,
322                "recovered transaction high-water mark is exhausted",
323            )?;
324            store.remember_startup_xid_high_water_mark(high);
325        }
326        // Turbo WAL is an index projection, while the persisted entity owns
327        // its content and metadata. Do not discard a vector that cannot be
328        // rebuilt exactly from the recovered entities.
329        for (collection, records) in store.replayed_turbo_inserts.lock().iter() {
330            has_vector_cache = true;
331            ensure(
332                crate::runtime::vector_turbo_kind::is_turbo(&store, collection),
333                "WAL vector collection is not a turbo collection",
334            )?;
335            for (id, vector) in records {
336                let entity = store
337                    .try_get(
338                        collection,
339                        crate::storage::EntityId::new(*id),
340                        32 * 1024 * 1024,
341                    )?
342                    .ok_or("WAL vector has no recovered entity")?;
343                let crate::storage::EntityData::Vector(data) = entity.data else {
344                    return Err("WAL vector points to a non-vector entity".into());
345                };
346                ensure(
347                    data.dense.len() == vector.len()
348                        && data
349                            .dense
350                            .iter()
351                            .zip(vector)
352                            .all(|(a, b)| a.to_bits() == b.to_bits()),
353                    "WAL vector differs from recovered entity",
354                )?;
355            }
356        }
357        has_vector_cache |= verify_turbo_snapshots(&staged_source, &store)?;
358        probabilistic_deltas = store.take_replayed_probabilistic_deltas();
359        store.persist()?;
360    }
361    if !probabilistic_deltas.is_empty() {
362        let options = crate::RedDBOptions::persistent(&staged_source)
363            .with_memory_budget(64 * 1024 * 1024)
364            .with_bounded_disk_residency(
365                guard.header.version >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
366            );
367        let runtime = crate::RedDBRuntime::with_options(options)?;
368        runtime.persist_offline_probabilistic_recovery(probabilistic_deltas)?;
369        runtime.checkpoint()?;
370        runtime.graceful_shutdown(false)?;
371    }
372    if has_vector_cache {
373        scrub::verify_recovery_vector_pages(&staged_source)?;
374    }
375    let validated_dir = stage.join("validated");
376    let mut report = compact_paged(&staged_source, &validated_dir)?;
377    report.retired_boot_system_wal_actions = boot_wal.action_count();
378    report.reconciled_system_collections = catalog_plan.reconciled_count();
379    report.imported_system_records = catalog_plan.imported_count();
380    let verified = scrub_paged(&validated_dir.join(name))?;
381    ensure(
382        verified.assets_verified
383            && verified.trees.findings.is_empty()
384            && verified.unclassified_pages == 0,
385        "replayed store failed scrub",
386    )?;
387    if let Some(audit) = preserved_audit {
388        restore_recovery_audit_logs(&audit, &validated_dir.join(name))?;
389    }
390    let final_path = destination.join(name);
391    copy_sidecars(&validated_dir.join(name), &final_path, false)?;
392    copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
393    write_new(
394        &destination.join("compaction.json"),
395        report.machine_json().as_bytes(),
396    )?;
397    sync_directory(destination)?;
398    std::fs::hard_link(validated_dir.join(name), &final_path)?;
399    sync_directory(destination)?;
400    // The published database must not retain a second pathname with a
401    // different WAL family: future opens through that name would be unsafe.
402    std::fs::remove_file(validated_dir.join(name))?;
403    sync_directory(&validated_dir)?;
404    Ok(report)
405}
406
407fn verify_wal_recovery_records(
408    wal: &Path,
409    retain_canonical_system_config: bool,
410    boot_wal: &boot_system_wal::BootSystemWalPlan,
411) -> Result<bool> {
412    use crate::storage::wal::{WalReader, WalRecord};
413    let mut pending_ownership = false;
414    for entry in WalReader::open(wal)?.iter() {
415        let (lsn, record) = entry?;
416        match record {
417            WalRecord::PageWrite { data, .. } => {
418                verify_wal_recovery_action(&data, retain_canonical_system_config)?
419            }
420            WalRecord::TxCommitBatch { actions, .. } => {
421                for (index, action) in actions.iter().enumerate() {
422                    if !boot_wal.skips_action(lsn, index) {
423                        verify_wal_recovery_action(action, retain_canonical_system_config)?;
424                    }
425                }
426            }
427            WalRecord::VectorInsert { collection, .. } => {
428                ensure(
429                    !retain_canonical_system_config || collection != "red_config",
430                    "WAL references divergent legacy red_config",
431                )?;
432            }
433            WalRecord::ProbabilisticDelta { name, .. } => {
434                ensure(
435                    !retain_canonical_system_config || name != "red_config",
436                    "WAL references divergent legacy red_config",
437                )?;
438            }
439            WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
440                pending_ownership = true
441            }
442            _ => {}
443        }
444    }
445    Ok(pending_ownership)
446}
447
448fn verify_wal_recovery_action(bytes: &[u8], retain_canonical_system_config: bool) -> Result<()> {
449    let action = f::decode_store_wal_action_frame(bytes)?;
450    if retain_canonical_system_config {
451        let name = match &action {
452            f::StoreWalActionFrame::CreateCollection { name }
453            | f::StoreWalActionFrame::DropCollection { name } => Some(name),
454            f::StoreWalActionFrame::UpsertEntityRecord { collection, .. }
455            | f::StoreWalActionFrame::DeleteEntityRecord { collection, .. }
456            | f::StoreWalActionFrame::BulkUpsertEntityRecords { collection, .. }
457            | f::StoreWalActionFrame::RefreshCollection { collection, .. } => Some(collection),
458            f::StoreWalActionFrame::SetAuxMetadata { bytes } => {
459                if bytes.first() == Some(&b'[') {
460                    let contracts: crate::serde_json::Value = crate::serde_json::from_slice(bytes)?;
461                    let contracts = contracts.as_array().ok_or("invalid WAL contract catalog")?;
462                    ensure(
463                        contracts.iter().all(|contract| {
464                            contract
465                                .get("name")
466                                .and_then(crate::serde_json::Value::as_str)
467                                != Some("red_config")
468                        }),
469                        "WAL references divergent legacy red_config",
470                    )?;
471                }
472                None
473            }
474        };
475        ensure(
476            name.is_none_or(|name| name != "red_config"),
477            "WAL references divergent legacy red_config",
478        )?;
479    }
480    Ok(())
481}
482
483#[cfg(test)]
484mod canonical_config_wal_tests {
485    use super::*;
486
487    #[test]
488    fn compaction_progress_is_machine_readable() {
489        let directory = tempfile::tempdir().expect("progress directory");
490        write_compaction_progress(directory.path(), "rewrite", 2, 5, 1_000)
491            .expect("write progress");
492        let progress: crate::serde_json::Value = crate::serde_json::from_slice(
493            &std::fs::read(directory.path().join("compaction.progress.json"))
494                .expect("progress file"),
495        )
496        .expect("progress JSON");
497        assert_eq!(progress["phase"].as_str(), Some("rewrite"));
498        assert_eq!(progress["completed_trees"].as_u64(), Some(2));
499        assert_eq!(progress["total_trees"].as_u64(), Some(5));
500        assert_eq!(progress["records"].as_u64(), Some(1_000));
501    }
502
503    #[test]
504    fn every_legacy_collection_action_is_rejected_before_name_normalization() {
505        use f::StoreWalActionFrame as Action;
506
507        let actions = [
508            Action::CreateCollection {
509                name: "red_config".into(),
510            },
511            Action::DropCollection {
512                name: "red_config".into(),
513            },
514            Action::UpsertEntityRecord {
515                collection: "red_config".into(),
516                record: vec![1],
517            },
518            Action::DeleteEntityRecord {
519                collection: "red_config".into(),
520                entity_id: 1,
521            },
522            Action::BulkUpsertEntityRecords {
523                collection: "red_config".into(),
524                records: vec![vec![1]],
525            },
526            Action::RefreshCollection {
527                collection: "red_config".into(),
528                records: vec![vec![1]],
529            },
530        ];
531        for action in actions {
532            let bytes = f::encode_store_wal_action_frame(&action).unwrap();
533            assert!(verify_wal_recovery_action(&bytes, false).is_ok());
534            assert!(verify_wal_recovery_action(&bytes, true)
535                .unwrap_err()
536                .to_string()
537                .contains("WAL references divergent legacy red_config"));
538        }
539        let canonical = f::encode_store_wal_action_frame(&Action::DeleteEntityRecord {
540            collection: "red.config".into(),
541            entity_id: 1,
542        })
543        .unwrap();
544        assert!(verify_wal_recovery_action(&canonical, true).is_ok());
545        let legacy_contract = f::encode_store_wal_action_frame(&Action::SetAuxMetadata {
546            bytes: br#"[{"name":"red\u005fconfig"}]"#.to_vec(),
547        })
548        .unwrap();
549        assert!(verify_wal_recovery_action(&legacy_contract, true).is_err());
550    }
551}
552
553fn verify_turbo_snapshots(
554    path: &Path,
555    store: &crate::storage::unified::UnifiedStore,
556) -> Result<bool> {
557    let snapshots = f::support_dir_for(path).join("snapshots");
558    if !snapshots.exists() {
559        return Ok(false);
560    }
561    let mut found = false;
562    for entry in std::fs::read_dir(snapshots)? {
563        let entry = entry?;
564        if entry.file_type()?.is_dir() {
565            continue;
566        }
567        found = true;
568        let path = entry.path();
569        let name = path
570            .file_stem()
571            .and_then(|name| name.to_str())
572            .ok_or("invalid turbo snapshot name")?;
573        ensure(
574            crate::runtime::vector_turbo_kind::is_turbo(store, name),
575            "snapshot has no turbo collection",
576        )?;
577        let length = entry.metadata()?.len();
578        ensure(
579            length <= 64 * 1024 * 1024,
580            "turbo snapshot exceeds recovery budget",
581        )?;
582        let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
583        File::open(&path)?.read_exact(&mut header)?;
584        let dim = u32::from_le_bytes(header[12..16].try_into()?);
585        let count = u64::from_le_bytes(header[32..40].try_into()?);
586        let record_size = u64::from(dim)
587            .checked_mul(4)
588            .and_then(|v| v.checked_add(12))
589            .ok_or("snapshot dimension overflow")?;
590        let expected = count
591            .checked_mul(record_size)
592            .and_then(|v| v.checked_add(header.len() as u64))
593            .ok_or("snapshot length overflow")?;
594        ensure(expected == length, "invalid turbo snapshot length")?;
595        let payload = f::read_turboquant_snapshot(
596            &path,
597            dim,
598            crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
599        )?;
600        for (id, vector) in payload.vectors {
601            let entity = store
602                .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
603                .ok_or("snapshot vector has no recovered entity")?;
604            let crate::storage::EntityData::Vector(data) = entity.data else {
605                return Err("snapshot points to a non-vector entity".into());
606            };
607            ensure(
608                data.dense.len() == vector.len()
609                    && data
610                        .dense
611                        .iter()
612                        .zip(&vector)
613                        .all(|(a, b)| a.to_bits() == b.to_bits()),
614                "snapshot vector differs from recovered entity",
615            )?;
616        }
617        // This verified cache predates WAL replay. Rebuild it from the durable
618        // entities instead of allowing a stale snapshot to hide recovered rows.
619        std::fs::remove_file(path)?;
620    }
621    Ok(found)
622}
623
624fn wal_has_records(path: &Path) -> Result<bool> {
625    match std::fs::metadata(path) {
626        Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
627        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
628        Err(error) => Err(error.into()),
629    }
630}
631
632fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
633    use crate::storage::wal::{WalReader, WalRecord};
634    let mut transactions = std::collections::HashMap::new();
635    let mut last_lsn = 0;
636    let mut highest_tx = 0u64;
637    let header = pager.header()?;
638    let mut target_seen = !header.checkpoint_in_progress;
639    for item in WalReader::open(path)?.iter() {
640        let (lsn, record) = item?;
641        last_lsn = lsn;
642        target_seen |= lsn == header.checkpoint_target_lsn;
643        match record {
644            WalRecord::Begin { tx_id } => {
645                highest_tx = highest_tx.max(tx_id);
646                transactions.insert(tx_id, false);
647            }
648            WalRecord::Commit { tx_id } => {
649                highest_tx = highest_tx.max(tx_id);
650                transactions.insert(tx_id, true);
651            }
652            WalRecord::Rollback { tx_id } => {
653                highest_tx = highest_tx.max(tx_id);
654                transactions.remove(&tx_id);
655            }
656            WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
657            _ => {}
658        }
659    }
660    ensure(
661        target_seen,
662        "engine WAL does not contain the interrupted checkpoint target",
663    )?;
664    // Store only the LSN of each latest committed page, not every page image.
665    let mut latest = std::collections::HashMap::new();
666    for item in WalReader::open(path)?.iter() {
667        let (lsn, record) = item?;
668        if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
669            if transactions.get(&tx_id).copied().unwrap_or(false) {
670                latest.insert(page_id, lsn);
671            }
672        }
673    }
674    ensure(
675        !header.checkpoint_in_progress || !latest.is_empty(),
676        "interrupted checkpoint has no committed page images",
677    )?;
678    if !latest.is_empty() {
679        pager.set_checkpoint_in_progress(true, last_lsn)?;
680        for item in WalReader::open(path)?.iter() {
681            let (lsn, record) = item?;
682            if let WalRecord::PageWrite { page_id, data, .. } = record {
683                if latest.get(&page_id) == Some(&lsn) {
684                    pager.write_page(page_id, Page::from_slice(&data)?)?;
685                }
686            }
687        }
688        pager.sync()?;
689        pager.complete_checkpoint(last_lsn)?;
690    }
691    Ok(highest_tx)
692}
693
694fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
695    for (old, new) in [
696        (
697            f::physical_metadata_binary_path(source),
698            f::physical_metadata_binary_path(destination),
699        ),
700        (
701            f::physical_metadata_json_path(source),
702            f::physical_metadata_json_path(destination),
703        ),
704    ] {
705        match std::fs::symlink_metadata(&old) {
706            Ok(stat) => {
707                ensure(
708                    stat.is_file() && stat.len() <= 64 * 1024 * 1024,
709                    "invalid physical metadata sidecar",
710                )?;
711                let mut input = File::open(&old)?;
712                let mut output = create_new(&new)?;
713                std::io::copy(&mut input, &mut output)?;
714                output.sync_all()?;
715                drop(output);
716                verify_file_copy(&old, &new)?;
717            }
718            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
719            Err(error) => return Err(error.into()),
720        }
721    }
722    Ok(())
723}
724
725fn compact_paged_inner(
726    source: &Path,
727    destination: &Path,
728    salvage: Option<&PagedIndexSalvageReport>,
729    lost_collections: Option<&PagedCollectionSalvageReport>,
730    retain_canonical_system_config: bool,
731) -> Result<CompactionReport> {
732    let source_path = source.canonicalize()?;
733    let parent = destination
734        .parent()
735        .filter(|parent| !parent.as_os_str().is_empty())
736        .unwrap_or_else(|| Path::new("."))
737        .canonicalize()?;
738    for source_directory in [
739        f::support_dir_for(&source_path),
740        source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
741    ] {
742        if source_directory.exists() {
743            ensure(
744                !parent.starts_with(source_directory.canonicalize()?),
745                "destination is inside source storage",
746            )?;
747        }
748    }
749    let source = Arc::new(Source::open(&source_path)?);
750    let mut catalog = Catalog::read(&source)?;
751    if retain_canonical_system_config {
752        catalog.retain_canonical_system_config()?;
753    }
754    ensure(
755        source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
756            || catalog.resume.is_some(),
757        "prepared disk-resident file has no resume catalog",
758    )?;
759    if let Some(salvage) = salvage {
760        let resume = catalog
761            .resume
762            .as_mut()
763            .ok_or("index recovery requires a resume catalog")?;
764        for finding in &salvage.indexes {
765            let state = resume
766                .collections
767                .get_mut(&finding.collection)
768                .ok_or("index collection missing from resume")?;
769            let index = state
770                .secondary_indices
771                .remove(&finding.index)
772                .ok_or("index missing from resume")?;
773            ensure(
774                index.root_page == finding.root_page,
775                "index root changed during salvage",
776            )?;
777        }
778    }
779    if let Some(losses) = lost_collections {
780        ensure(
781            catalog.resume.is_none() && !catalog.has_cross_references(),
782            "collection recovery requires a reference-free legacy catalog",
783        )?;
784        for loss in &losses.collections {
785            let root = catalog
786                .roots
787                .iter_mut()
788                .find(|root| root.collection == loss.collection)
789                .ok_or("collection missing from catalog")?;
790            ensure(
791                root.root_page == loss.root_page,
792                "collection root changed during salvage",
793            )?;
794            if loss.skipped_pages == [loss.root_page] {
795                root.root_page = 0;
796            }
797        }
798    }
799    let name = source_path
800        .file_name()
801        .ok_or("source filename is missing")?;
802    // Exclusive creation prevents clobbering files, symlinks and other runs.
803    std::fs::create_dir(destination)?;
804    #[cfg(unix)]
805    {
806        use std::os::unix::fs::PermissionsExt;
807        std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
808    }
809    let total_trees = catalog.roots.len()
810        + catalog.resume.as_ref().map_or(0, |resume| {
811            resume
812                .collections
813                .values()
814                .map(|state| {
815                    usize::from(state.key_index_root.is_some()) + state.secondary_indices.len()
816                })
817                .sum::<usize>()
818        });
819    let mut completed_trees = 0usize;
820    write_compaction_progress(destination, "rewrite", completed_trees, total_trees, 0)?;
821    let output = destination.join(name);
822    let temporary = destination.join(".compacting.rdb");
823    ensure(output != temporary, "reserved compaction source filename")?;
824    let target = Arc::new(Pager::open(
825        &temporary,
826        PagerConfig {
827            cache_size: 1024,
828            disk_residency: source.header.version
829                >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
830            ..PagerConfig::default()
831        },
832    )?);
833    let mut roots = BTreeMap::new();
834    let mut recovered_trees = BTreeMap::new();
835    let mut records = 0;
836    let mut repaired_leaf_backlinks = 0;
837    for collection in &catalog.roots {
838        let loss = lost_collections.and_then(|report| {
839            report
840                .collections
841                .iter()
842                .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
843        });
844        let count = if let Some(loss) = loss {
845            let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
846            recovered_trees.insert(loss.root_page, (count, hash));
847            count
848        } else {
849            rewrite_tree(
850                &source,
851                &target,
852                collection.root_page,
853                &mut roots,
854                &mut repaired_leaf_backlinks,
855            )?
856        };
857        if let Some(resume) = &catalog.resume {
858            ensure(
859                resume.collections[&collection.collection].entities == count,
860                "collection count disagrees with resume",
861            )?;
862        }
863        records += count;
864        completed_trees += 1;
865        write_compaction_progress(
866            destination,
867            "rewrite",
868            completed_trees,
869            total_trees,
870            records,
871        )?;
872    }
873    if let Some(resume) = &catalog.resume {
874        for state in resume.collections.values() {
875            if let Some(root) = state.key_index_root {
876                let count = rewrite_tree(
877                    &source,
878                    &target,
879                    root,
880                    &mut roots,
881                    &mut repaired_leaf_backlinks,
882                )?;
883                records += count;
884                completed_trees += 1;
885                write_compaction_progress(
886                    destination,
887                    "rewrite",
888                    completed_trees,
889                    total_trees,
890                    records,
891                )?;
892            }
893            for index in state.secondary_indices.values() {
894                let count = rewrite_tree(
895                    &source,
896                    &target,
897                    index.root_page,
898                    &mut roots,
899                    &mut repaired_leaf_backlinks,
900                )?;
901                records += count;
902                completed_trees += 1;
903                write_compaction_progress(
904                    destination,
905                    "rewrite",
906                    completed_trees,
907                    total_trees,
908                    records,
909                )?;
910            }
911        }
912    }
913    write_compaction_progress(destination, "assets", completed_trees, total_trees, records)?;
914    let mut assets = assets::Assets::new(&source, &target);
915    assets.vault()?;
916    let physical = assets.physical()?;
917    let internal = internal_metadata::read(&source, &catalog)?;
918    let rewritten_document = assets.metadata(
919        &source_path,
920        Some(&output),
921        internal.as_ref().map(|record| record.document.as_str()),
922    )?;
923    let mut rewritten_metadata = None;
924    if let (Some(record), Some(document)) = (internal, rewritten_document) {
925        let old_root = record.root;
926        let key = record.key.clone();
927        let value = record.rewrite(document)?;
928        let tree = BTree::with_root(Arc::clone(&target), roots[&old_root]);
929        tree.upsert(&key, &value)?;
930        roots.insert(old_root, tree.root_page_id());
931        rewritten_metadata = Some((old_root, key, value));
932    }
933    catalog.claim_retired(&source)?;
934    if salvage.is_some() || lost_collections.is_some() {
935        if let Some(salvage) = salvage {
936            for index in &salvage.indexes {
937                if index.root_page != 0 {
938                    match source.raw_page(index.root_page, true) {
939                        Ok(_) => {
940                            let tree = BTree::with_root(Arc::clone(&source), index.root_page);
941                            let skipped = tree.visit_recoverable_leaves(
942                                |id| {
943                                    source.claim(id).map_err(|error| {
944                                        reddb_engine::storage::btree::BTreeError::Corrupted(
945                                            error.to_string(),
946                                        )
947                                    })
948                                },
949                                |_, _| Ok(()),
950                            )?;
951                            ensure(
952                                !skipped.is_empty(),
953                                "index damage is not a verified leaf checksum failure",
954                            )?;
955                        }
956                        Err(error) => {
957                            ensure(
958                                matches!(
959                                    error.downcast_ref::<reddb_engine::storage::page::PageError>(),
960                                    Some(
961                                        reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
962                                    )
963                                ),
964                                "index root failure is not a checksum mismatch",
965                            )?;
966                            ensure(
967                                source.raw_page(index.root_page, false)?.page_type()?
968                                    == PageType::BTreeLeaf,
969                                "damaged interior index root cannot be recovered",
970                            )?;
971                            source.claim(index.root_page)?;
972                        }
973                    }
974                }
975            }
976        }
977        if let Some(losses) = lost_collections {
978            for collection in &losses.collections {
979                if collection.skipped_pages == [collection.root_page] {
980                    source.claim(collection.root_page)?;
981                }
982            }
983        }
984        ensure(
985            source.unclaimed_pages() == 0,
986            "unclassified source pages require separate recovery verification",
987        )?;
988    }
989    catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
990    target.update_physical_header(physical)?;
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}