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