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