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::{Read, Write},
15 path::Path,
16 sync::Arc,
17};
18mod assets;
19mod catalog;
20mod salvage;
21mod scrub;
22mod source;
23use catalog::Catalog;
24pub use salvage::{
25 salvage_paged_collections, salvage_paged_indexes, PagedCollectionLoss,
26 PagedCollectionSalvageReport, PagedIndexSalvageFinding, PagedIndexSalvageReport,
27};
28pub use scrub::{
29 scrub_paged, scrub_paged_trees, PagedScrubReport, PagedTreeFinding, PagedTreeScrubReport,
30};
31use source::Source;
32type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
33
34#[derive(Debug)]
35pub struct CompactionReport {
36 pub source_bytes: u64,
37 pub destination_bytes: u64,
38 pub source_allocated_bytes: Option<u64>,
39 pub destination_allocated_bytes: Option<u64>,
40 pub records: u64,
42 pub trees: usize,
43 pub repaired_leaf_backlinks: u64,
45}
46
47impl CompactionReport {
48 pub fn machine_json(&self) -> String {
49 format!(
50 r#"{{"source_bytes":{},"destination_bytes":{},"records":{},"trees":{},"repaired_leaf_backlinks":{},"source_allocated_bytes":{},"destination_allocated_bytes":{}}}"#,
51 self.source_bytes,
52 self.destination_bytes,
53 self.records,
54 self.trees,
55 self.repaired_leaf_backlinks,
56 self.source_allocated_bytes
57 .map_or_else(|| "null".into(), |n| n.to_string()),
58 self.destination_allocated_bytes
59 .map_or_else(|| "null".into(), |n| n.to_string())
60 )
61 }
62}
63
64pub fn compact_paged(source: &Path, destination: &Path) -> Result<CompactionReport> {
68 compact_paged_inner(source, destination, None, None, false)
69}
70
71pub fn compact_paged_canonical_system_config(
75 source: &Path,
76 destination: &Path,
77) -> Result<CompactionReport> {
78 compact_paged_inner(source, destination, None, None, true)
79}
80
81pub fn recover_paged_wal(source: &Path, destination: &Path) -> Result<CompactionReport> {
85 use crate::storage::wal::{WalReader, WalRecord};
86 let source = source.canonicalize()?;
87 let parent = destination
88 .parent()
89 .filter(|p| !p.as_os_str().is_empty())
90 .unwrap_or_else(|| Path::new("."))
91 .canonicalize()?;
92 for owned in [
93 f::support_dir_for(&source),
94 source.with_file_name(format!("{}.ops", f::data_file_name(&source))),
95 ] {
96 if owned.exists() {
97 ensure(
98 !parent.starts_with(owned.canonicalize()?),
99 "destination is inside source storage",
100 )?;
101 }
102 }
103 let guard = Source::open_for_wal_recovery(&source)?;
104 guard.verify_superblocks()?;
105 salvage::verify_standalone_sidecars(&source)?;
106 let wal = f::unified_wal_path(&source);
107 let engine_wal = f::engine_wal_path(&source);
108 let pending_unified = wal_has_records(&wal)?;
109 let pending_engine = wal_has_records(&engine_wal)?;
110 ensure(
111 pending_unified
112 || pending_engine
113 || guard.pending_double_write
114 || guard.header.checkpoint_in_progress,
115 "no pending recovery",
116 )?;
117 ensure(
118 !guard.header.checkpoint_in_progress || pending_engine,
119 "interrupted page checkpoint requires its engine WAL",
120 )?;
121 let mut pending_ownership = false;
122 if pending_unified {
123 for entry in WalReader::open(&wal)?.iter() {
124 match entry?.1 {
125 WalRecord::PageWrite { data, .. } => verify_wal_recovery_action(&data)?,
126 WalRecord::TxCommitBatch { actions, .. } => {
127 for action in actions {
128 verify_wal_recovery_action(&action)?;
129 }
130 }
131 WalRecord::VectorInsert { .. } | WalRecord::ProbabilisticDelta { .. } => {
132 return Err(
133 "WAL contains runtime-owned data requiring separate recovery".into(),
134 );
135 }
136 WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
137 pending_ownership = true
138 }
139 _ => {}
140 }
141 }
142 }
143 if !guard.pending_double_write
144 && !guard.header.checkpoint_in_progress
145 && !pending_ownership
146 && !pending_engine
147 {
148 salvage::verify_vault(&source, &guard)?;
149 }
150 let mut recovered_engine_xid = 0;
151 if pending_engine {
152 for entry in WalReader::open(&engine_wal)?.iter() {
153 match entry?.1 {
154 WalRecord::Begin { .. }
155 | WalRecord::Commit { .. }
156 | WalRecord::Rollback { .. }
157 | WalRecord::Checkpoint { .. } => {}
158 WalRecord::PageWrite { page_id, data, .. } => {
159 ensure(
160 page_id > 0
161 && (page_id < DWB_ZONE_START_PAGE || page_id >= FIRST_ALLOCATABLE_PAGE)
162 && u64::from(page_id) < guard.bytes.div_ceil(PAGE_SIZE as u64)
163 && data.len() == PAGE_SIZE,
164 "invalid engine WAL page",
165 )?;
166 let page = Page::from_slice(&data)?;
167 ensure(
168 page.page_id() == page_id,
169 "engine WAL page identity mismatch",
170 )?;
171 page.page_type()?;
172 }
176 _ => return Err("unsupported engine WAL record".into()),
177 }
178 }
179 }
180 std::fs::create_dir(destination)?;
181 #[cfg(unix)]
182 {
183 use std::os::unix::fs::PermissionsExt;
184 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
185 }
186 let stage = destination.join(".wal-replay");
187 std::fs::create_dir(&stage)?;
188 let name = source.file_name().ok_or("source filename is missing")?;
189 let staged_source = stage.join(name);
190 guard.copy_snapshot(&staged_source)?;
191 copy_sidecars(&source, &staged_source)?;
192 copy_physical_metadata_files(&source, &staged_source)?;
193 let config = crate::storage::unified::UnifiedStoreConfig {
194 resident_memory_bytes_max: (guard.header.version
195 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
196 .then_some(64 * 1024 * 1024),
197 ..Default::default()
198 };
199 if pending_engine {
200 let pager = Pager::open(
201 &staged_source,
202 PagerConfig {
203 disk_residency: config.resident_memory_bytes_max.is_some(),
204 ..PagerConfig::default()
205 },
206 )?;
207 let wal = f::engine_wal_path(&staged_source);
208 recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
209 let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
210 writer.truncate()?;
211 writer.sync()?;
212 }
213 {
214 let store =
215 crate::storage::unified::UnifiedStore::open_with_config(&staged_source, config)?;
216 if pending_engine || guard.pending_double_write {
217 store.reconcile_physical_recovery()?;
218 let mut high = store
222 .startup_xid_high_water_mark()
223 .unwrap_or(0)
224 .max(recovered_engine_xid);
225 for name in store.list_collections() {
226 let manager = store
227 .get_collection(&name)
228 .ok_or("recovered collection disappeared")?;
229 let mut max_entity = 0u64;
230 let mut max_row = 0u64;
231 let mut max_sequence = 0u64;
232 let mut has_entity = false;
233 manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
234 has_entity = true;
235 high = high.max(entity.xmin).max(entity.xmax);
236 max_entity = max_entity.max(entity.id.raw());
237 max_sequence = max_sequence.max(entity.sequence_id);
238 if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
239 max_row = max_row.max(*row_id);
240 }
241 true
242 })?;
243 if !has_entity {
244 continue;
245 }
246 let entity_next = max_entity
247 .checked_add(1)
248 .ok_or("recovered entity IDs are exhausted")?;
249 let row_next = max_row
250 .checked_add(1)
251 .ok_or("recovered row IDs are exhausted")?;
252 let sequence_next = max_sequence
253 .checked_add(1)
254 .ok_or("recovered sequence IDs are exhausted")?;
255 manager.restore_identity_counters(entity_next, row_next);
256 manager.restore_sequence_id_next(sequence_next);
257 store.register_entity_id(crate::storage::EntityId::new(max_entity));
258 }
259 ensure(
260 high < u64::MAX,
261 "recovered transaction high-water mark is exhausted",
262 )?;
263 store.remember_startup_xid_high_water_mark(high);
264 }
265 store.persist()?;
266 }
267 let validated_dir = stage.join("validated");
268 let report = compact_paged(&staged_source, &validated_dir)?;
269 let verified = scrub_paged(&validated_dir.join(name))?;
270 ensure(
271 verified.assets_verified
272 && verified.trees.findings.is_empty()
273 && verified.unclassified_pages == 0,
274 "replayed store failed scrub",
275 )?;
276 let final_path = destination.join(name);
277 copy_sidecars(&validated_dir.join(name), &final_path)?;
278 copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
279 write_new(
280 &destination.join("compaction.json"),
281 report.machine_json().as_bytes(),
282 )?;
283 sync_directory(destination)?;
284 std::fs::hard_link(validated_dir.join(name), &final_path)?;
285 sync_directory(destination)?;
286 std::fs::remove_file(validated_dir.join(name))?;
289 sync_directory(&validated_dir)?;
290 Ok(report)
291}
292
293fn verify_wal_recovery_action(bytes: &[u8]) -> Result<()> {
294 f::decode_store_wal_action_frame(bytes)?;
295 Ok(())
296}
297
298fn wal_has_records(path: &Path) -> Result<bool> {
299 match std::fs::metadata(path) {
300 Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
301 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
302 Err(error) => Err(error.into()),
303 }
304}
305
306fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
307 use crate::storage::wal::{WalReader, WalRecord};
308 let mut transactions = std::collections::HashMap::new();
309 let mut last_lsn = 0;
310 let mut highest_tx = 0u64;
311 let header = pager.header()?;
312 let mut target_seen = !header.checkpoint_in_progress;
313 for item in WalReader::open(path)?.iter() {
314 let (lsn, record) = item?;
315 last_lsn = lsn;
316 target_seen |= lsn == header.checkpoint_target_lsn;
317 match record {
318 WalRecord::Begin { tx_id } => {
319 highest_tx = highest_tx.max(tx_id);
320 transactions.insert(tx_id, false);
321 }
322 WalRecord::Commit { tx_id } => {
323 highest_tx = highest_tx.max(tx_id);
324 transactions.insert(tx_id, true);
325 }
326 WalRecord::Rollback { tx_id } => {
327 highest_tx = highest_tx.max(tx_id);
328 transactions.remove(&tx_id);
329 }
330 WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
331 _ => {}
332 }
333 }
334 ensure(
335 target_seen,
336 "engine WAL does not contain the interrupted checkpoint target",
337 )?;
338 let mut latest = std::collections::HashMap::new();
340 for item in WalReader::open(path)?.iter() {
341 let (lsn, record) = item?;
342 if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
343 if transactions.get(&tx_id).copied().unwrap_or(false) {
344 latest.insert(page_id, lsn);
345 }
346 }
347 }
348 ensure(
349 !header.checkpoint_in_progress || !latest.is_empty(),
350 "interrupted checkpoint has no committed page images",
351 )?;
352 if !latest.is_empty() {
353 pager.set_checkpoint_in_progress(true, last_lsn)?;
354 for item in WalReader::open(path)?.iter() {
355 let (lsn, record) = item?;
356 if let WalRecord::PageWrite { page_id, data, .. } = record {
357 if latest.get(&page_id) == Some(&lsn) {
358 pager.write_page(page_id, Page::from_slice(&data)?)?;
359 }
360 }
361 }
362 pager.sync()?;
363 pager.complete_checkpoint(last_lsn)?;
364 }
365 Ok(highest_tx)
366}
367
368fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
369 for (old, new) in [
370 (
371 f::physical_metadata_binary_path(source),
372 f::physical_metadata_binary_path(destination),
373 ),
374 (
375 f::physical_metadata_json_path(source),
376 f::physical_metadata_json_path(destination),
377 ),
378 ] {
379 match std::fs::symlink_metadata(&old) {
380 Ok(stat) => {
381 ensure(
382 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
383 "invalid physical metadata sidecar",
384 )?;
385 let mut input = File::open(&old)?;
386 let mut output = create_new(&new)?;
387 std::io::copy(&mut input, &mut output)?;
388 output.sync_all()?;
389 drop(output);
390 verify_file_copy(&old, &new)?;
391 }
392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
393 Err(error) => return Err(error.into()),
394 }
395 }
396 Ok(())
397}
398
399fn compact_paged_inner(
400 source: &Path,
401 destination: &Path,
402 salvage: Option<&PagedIndexSalvageReport>,
403 lost_collections: Option<&PagedCollectionSalvageReport>,
404 retain_canonical_system_config: bool,
405) -> Result<CompactionReport> {
406 let source_path = source.canonicalize()?;
407 let parent = destination
408 .parent()
409 .filter(|parent| !parent.as_os_str().is_empty())
410 .unwrap_or_else(|| Path::new("."))
411 .canonicalize()?;
412 for source_directory in [
413 f::support_dir_for(&source_path),
414 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
415 ] {
416 if source_directory.exists() {
417 ensure(
418 !parent.starts_with(source_directory.canonicalize()?),
419 "destination is inside source storage",
420 )?;
421 }
422 }
423 let source = Arc::new(Source::open(&source_path)?);
424 let mut catalog = Catalog::read(&source)?;
425 if retain_canonical_system_config {
426 catalog.retain_canonical_system_config()?;
427 }
428 ensure(
429 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
430 || catalog.resume.is_some(),
431 "prepared disk-resident file has no resume catalog",
432 )?;
433 if let Some(salvage) = salvage {
434 let resume = catalog
435 .resume
436 .as_mut()
437 .ok_or("index recovery requires a resume catalog")?;
438 for finding in &salvage.indexes {
439 let state = resume
440 .collections
441 .get_mut(&finding.collection)
442 .ok_or("index collection missing from resume")?;
443 let index = state
444 .secondary_indices
445 .remove(&finding.index)
446 .ok_or("index missing from resume")?;
447 ensure(
448 index.root_page == finding.root_page,
449 "index root changed during salvage",
450 )?;
451 }
452 }
453 if let Some(losses) = lost_collections {
454 ensure(
455 catalog.resume.is_none() && !catalog.has_cross_references(),
456 "collection recovery requires a reference-free legacy catalog",
457 )?;
458 for loss in &losses.collections {
459 let root = catalog
460 .roots
461 .iter_mut()
462 .find(|root| root.collection == loss.collection)
463 .ok_or("collection missing from catalog")?;
464 ensure(
465 root.root_page == loss.root_page,
466 "collection root changed during salvage",
467 )?;
468 if loss.skipped_pages == [loss.root_page] {
469 root.root_page = 0;
470 }
471 }
472 }
473 let name = source_path
474 .file_name()
475 .ok_or("source filename is missing")?;
476 std::fs::create_dir(destination)?;
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
482 }
483 let output = destination.join(name);
484 let temporary = destination.join(".compacting.rdb");
485 ensure(output != temporary, "reserved compaction source filename")?;
486 let target = Arc::new(Pager::open(
487 &temporary,
488 PagerConfig {
489 cache_size: 1024,
490 disk_residency: source.header.version
491 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
492 ..PagerConfig::default()
493 },
494 )?);
495 let mut roots = BTreeMap::new();
496 let mut recovered_trees = BTreeMap::new();
497 let mut records = 0;
498 let mut repaired_leaf_backlinks = 0;
499 for collection in &catalog.roots {
500 let loss = lost_collections.and_then(|report| {
501 report
502 .collections
503 .iter()
504 .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
505 });
506 let count = if let Some(loss) = loss {
507 let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
508 recovered_trees.insert(loss.root_page, (count, hash));
509 count
510 } else {
511 rewrite_tree(
512 &source,
513 &target,
514 collection.root_page,
515 &mut roots,
516 &mut repaired_leaf_backlinks,
517 )?
518 };
519 if let Some(resume) = &catalog.resume {
520 ensure(
521 resume.collections[&collection.collection].entities == count,
522 "collection count disagrees with resume",
523 )?;
524 }
525 records += count;
526 }
527 if let Some(resume) = &catalog.resume {
528 for state in resume.collections.values() {
529 if let Some(root) = state.key_index_root {
530 records += rewrite_tree(
531 &source,
532 &target,
533 root,
534 &mut roots,
535 &mut repaired_leaf_backlinks,
536 )?;
537 }
538 for index in state.secondary_indices.values() {
539 records += rewrite_tree(
540 &source,
541 &target,
542 index.root_page,
543 &mut roots,
544 &mut repaired_leaf_backlinks,
545 )?;
546 }
547 }
548 }
549 let mut assets = assets::Assets::new(&source, &target);
550 assets.vault()?;
551 let physical = assets.physical()?;
552 assets.metadata(&source_path, Some(&output))?;
553 if salvage.is_some() || lost_collections.is_some() {
554 if let Some(salvage) = salvage {
555 for index in &salvage.indexes {
556 if index.root_page != 0 {
557 match source.raw_page(index.root_page, true) {
558 Ok(_) => {
559 let tree = BTree::with_root(Arc::clone(&source), index.root_page);
560 let skipped = tree.visit_recoverable_leaves(
561 |id| {
562 source.claim(id).map_err(|error| {
563 reddb_engine::storage::btree::BTreeError::Corrupted(
564 error.to_string(),
565 )
566 })
567 },
568 |_, _| Ok(()),
569 )?;
570 ensure(
571 !skipped.is_empty(),
572 "index damage is not a verified leaf checksum failure",
573 )?;
574 }
575 Err(error) => {
576 ensure(
577 matches!(
578 error.downcast_ref::<reddb_engine::storage::page::PageError>(),
579 Some(
580 reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
581 )
582 ),
583 "index root failure is not a checksum mismatch",
584 )?;
585 ensure(
586 source.raw_page(index.root_page, false)?.page_type()?
587 == PageType::BTreeLeaf,
588 "damaged interior index root cannot be recovered",
589 )?;
590 source.claim(index.root_page)?;
591 }
592 }
593 }
594 }
595 }
596 if let Some(losses) = lost_collections {
597 for collection in &losses.collections {
598 if collection.skipped_pages == [collection.root_page] {
599 source.claim(collection.root_page)?;
600 }
601 }
602 }
603 ensure(
604 source.unclaimed_pages() == 0,
605 "unclassified source pages require separate recovery verification",
606 )?;
607 }
608 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
609 target.update_physical_header(physical)?;
610 target.fence_file_format_version(source.header.version)?;
611 target.sync()?;
612 for _ in 0..2 {
614 target.commit_superblock(|slot| {
615 let mut header = f::decode_database_header(slot).map_err(|error| {
616 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
617 })?;
618 header.schema_version = source.header.schema_version;
619 header.checkpoint_lsn = source.header.checkpoint_lsn;
620 f::encode_database_header(slot, &header).map_err(|error| {
621 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
622 })
623 })?;
624 }
625 if lost_collections.is_some() {
626 for _ in 0..2 {
629 target.commit_superblock(|slot| {
630 f::set_database_header_version(
631 slot,
632 f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
633 )
634 .map_err(|error| {
635 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
636 })
637 })?;
638 }
639 }
640 let assets_verification = assets.into_verification();
641 drop(target);
642 let reopened = Arc::new(Source::open(&temporary)?);
643 if salvage.is_some() || lost_collections.is_some() {
644 salvage::verify_vault(&temporary, &reopened)?;
645 }
646 ensure(
647 reopened.header.physical == physical,
648 "rewritten physical header failed parity",
649 )?;
650 let rewritten_catalog = Catalog::read(&reopened)?;
651 ensure(
652 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
653 "rewritten catalog differs from source",
654 )?;
655 for (&old, &new) in &roots {
656 if let Some((count, hash)) = recovered_trees.get(&old) {
657 verify_recovered_tree(&reopened, new, *count, *hash)?;
658 } else {
659 verify_tree(&source, &reopened, old, new)?;
660 }
661 }
662 for (id, expected, checksum) in assets_verification {
663 let page = reopened.raw_page(id, checksum)?;
664 if id != 2 {
665 reopened.claim(id)?;
666 }
667 ensure(
668 blake3::hash(page.content()) == expected,
669 "native asset failed readback parity",
670 )?;
671 }
672 let report = CompactionReport {
673 source_bytes: source.bytes,
674 destination_bytes: reopened.bytes,
675 source_allocated_bytes: allocated_bytes(&source_path)?,
676 destination_allocated_bytes: allocated_bytes(&temporary)?,
677 records,
678 trees: roots.len(),
679 repaired_leaf_backlinks,
680 };
681 drop(reopened);
682 copy_sidecars(&source_path, &output)?;
683 if retain_canonical_system_config {
684 let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
685 if operational_path.exists() {
686 crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
687 .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
688 }
689 }
690 write_new(
691 &destination.join("compaction.json"),
692 report.machine_json().as_bytes(),
693 )?;
694 if let Some(salvage) = salvage {
695 write_new(
696 &destination.join("salvage.json"),
697 salvage.machine_json().as_bytes(),
698 )?;
699 }
700 if let Some(losses) = lost_collections {
701 write_new(
702 &destination.join("salvage.json"),
703 losses.machine_json().as_bytes(),
704 )?;
705 }
706 sync_directory(destination)?;
707 std::fs::hard_link(&temporary, &output)?;
709 sync_directory(destination)?;
710 std::fs::remove_file(&temporary)?;
711 sync_directory(destination)?;
712 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
713 sync_directory(parent)?;
714 }
715 Ok(report)
716}
717fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
718 roots.values().map(|root| (*root, *root)).collect()
719}
720
721fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
722 hasher.update(&(key.len() as u64).to_le_bytes());
723 hasher.update(key);
724 hasher.update(&(value.len() as u64).to_le_bytes());
725 hasher.update(value);
726}
727
728fn rewrite_recoverable_tree(
729 source: &Arc<Source>,
730 target: &Arc<Pager>,
731 loss: &PagedCollectionLoss,
732 roots: &mut BTreeMap<u32, u32>,
733) -> Result<(u64, blake3::Hash)> {
734 ensure(
735 !roots.contains_key(&loss.root_page),
736 "duplicate live tree owner",
737 )?;
738 let original = BTree::with_root(Arc::clone(source), loss.root_page);
739 let destination = BTree::new(Arc::clone(target));
740 let mut hash = blake3::Hasher::new();
741 let mut count = 0u64;
742 let mut batch = Vec::new();
743 let mut size = 0usize;
744 let skipped = original.visit_recoverable_leaves(
745 |id| {
746 source.claim(id).map_err(|error| {
747 reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
748 })
749 },
750 |key, value| {
751 hash_record(&mut hash, &key, &value);
752 size += key.len() + value.len();
753 batch.push((key, value));
754 count += 1;
755 if size >= 1024 * 1024 || batch.len() >= 512 {
756 destination.bulk_insert_sorted(&batch)?;
757 batch.clear();
758 size = 0;
759 }
760 Ok(())
761 },
762 )?;
763 ensure(
764 skipped == loss.skipped_pages,
765 "damaged pages changed during recovery",
766 )?;
767 destination.bulk_insert_sorted(&batch)?;
768 roots.insert(loss.root_page, destination.root_page_id());
769 Ok((count, hash.finalize()))
770}
771
772fn verify_recovered_tree(
773 source: &Arc<Source>,
774 root: u32,
775 count: u64,
776 expected: blake3::Hash,
777) -> Result<()> {
778 let tree = BTree::with_root(Arc::clone(source), root);
779 let (owned, _) = inventory_tree(source, &tree, false)?;
780 ensure(owned == count, "recovered tree record count differs")?;
781 let mut cursor = tree.cursor_first()?;
782 let mut hash = blake3::Hasher::new();
783 let mut actual = 0u64;
784 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
785 hash_record(&mut hash, &key, &value);
786 actual += 1;
787 ensure(actual <= count, "recovered tree has extra records")?;
788 }
789 ensure(
790 actual == count && hash.finalize() == expected,
791 "recovered tree failed record parity",
792 )
793}
794
795fn rewrite_tree(
796 source: &Arc<Source>,
797 target: &Arc<Pager>,
798 root: u32,
799 roots: &mut BTreeMap<u32, u32>,
800 repaired_leaf_backlinks: &mut u64,
801) -> Result<u64> {
802 if root == 0 {
803 return Ok(0);
804 }
805 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
806 let tree = BTree::with_root(Arc::clone(source), root);
807 let (expected, repairs) = inventory_tree(source, &tree, true)?;
808 *repaired_leaf_backlinks += repairs;
809 let destination = BTree::new(Arc::clone(target));
810 let mut cursor = tree.cursor_first()?;
811 let mut count = 0;
812 let mut previous: Option<Vec<u8>> = None;
813 let mut batch = Vec::new();
814 let mut bytes = 0;
815 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
816 ensure(
817 previous.as_ref().is_none_or(|old| old < &key),
818 "source tree keys are not strictly ordered",
819 )?;
820 previous = Some(key.clone());
821 bytes += key.len() + value.len();
822 batch.push((key, value));
823 count += 1;
824 ensure(
825 count <= expected,
826 "tree cursor exceeds structural inventory",
827 )?;
828 if bytes >= 1024 * 1024 || batch.len() >= 512 {
829 destination.bulk_insert_sorted(&batch)?;
830 batch.clear();
831 bytes = 0;
832 }
833 }
834 destination.bulk_insert_sorted(&batch)?;
835 ensure(count == expected, "tree cursor omits owned records")?;
836 roots.insert(root, destination.root_page_id());
837 Ok(count)
838}
839fn inventory_tree(
843 source: &Source,
844 tree: &BTree<Source>,
845 allow_backlinks: bool,
846) -> Result<(u64, u64)> {
847 let pages = tree.retired_pages()?;
848 let mut leaves = Vec::new();
849 let mut expected = 0u64;
850 for id in pages {
851 source.claim(id)?;
852 let page = source.raw_page(id, true)?;
853 if page.page_type()? == PageType::BTreeLeaf {
854 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
855 expected += u64::from(page.cell_count());
856 }
857 }
858 leaves.reverse();
861 let mut repaired = 0;
862 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
863 ensure(
864 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
865 &format!(
866 "broken forward leaf chain at page {id}, root {}",
867 tree.root_page_id()
868 ),
869 )?;
870 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
871 ensure(
872 allow_backlinks,
873 &format!("broken backward leaf chain at page {id}"),
874 )?;
875 repaired += 1;
876 }
877 }
878 Ok((expected, repaired))
879}
880fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
881 let old = BTree::with_root(Arc::clone(source), old);
882 let new = BTree::with_root(Arc::clone(destination), new);
883 inventory_tree(destination, &new, false)?;
884 let mut left = old.cursor_first()?;
885 let mut right = new.cursor_first()?;
886 loop {
887 let expected = left.next_with_budget(32 * 1024 * 1024)?;
888 let actual = right.next_with_budget(32 * 1024 * 1024)?;
889 ensure(
890 expected == actual,
891 "compacted tree failed complete key/value parity",
892 )?;
893 if expected.is_none() {
894 return Ok(());
895 }
896 }
897}
898fn copy_sidecars(source: &Path, destination: &Path) -> Result<()> {
899 for (old, new) in [
902 (
903 f::unified_wal_path(source),
904 f::unified_wal_path(destination),
905 ),
906 (f::engine_wal_path(source), f::engine_wal_path(destination)),
907 (
908 f::logical_wal_path(source),
909 f::logical_wal_path(destination),
910 ),
911 (
912 f::legacy_logical_slots_path(source),
913 f::legacy_logical_slots_path(destination),
914 ),
915 (
916 f::legacy_audit_log_path(source),
917 f::legacy_audit_log_path(destination),
918 ),
919 ] {
920 match File::open(&old) {
921 Ok(mut input) => {
922 let mut output = create_new(&new)?;
923 std::io::copy(&mut input, &mut output)?;
924 output.sync_all()?;
925 verify_file_copy(&old, &new)?;
926 }
927 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
928 Err(error) => return Err(error.into()),
929 }
930 }
931 let support = f::support_dir_for(source);
932 if support.exists() {
933 validate_empty_directory(&support, 0)?;
934 copy_directory(&support, &f::support_dir_for(destination), 0)?;
935 }
936 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
937 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
938 if old.exists() {
939 validate_operational(&old)?;
940 copy_directory(&old, &new, 0)?;
941 }
942 Ok(())
943}
944fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
945 ensure(depth < 32, "operational directory nesting exceeds limit")?;
946 ensure(
947 std::fs::symlink_metadata(source)?.is_dir(),
948 "operational directory must not be a symlink",
949 )?;
950 std::fs::create_dir(destination)?;
951 for entry in std::fs::read_dir(source)? {
952 let entry = entry?;
953 let kind = entry.file_type()?;
954 let output = destination.join(entry.file_name());
955 if kind.is_dir() {
956 copy_directory(&entry.path(), &output, depth + 1)?;
957 } else {
958 ensure(
959 kind.is_file(),
960 "operational directory contains a symlink or special file",
961 )?;
962 let mut input = File::open(entry.path())?;
963 let mut target = create_new(&output)?;
964 std::io::copy(&mut input, &mut target)?;
965 target.sync_all()?;
966 verify_file_copy(&entry.path(), &output)?;
967 }
968 }
969 sync_directory(destination)
970}
971fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
972 ensure(depth < 32, "support directory nesting exceeds limit")?;
973 ensure(
974 std::fs::symlink_metadata(path)?.is_dir(),
975 "support directory must not be a symlink",
976 )?;
977 for entry in std::fs::read_dir(path)? {
978 let entry = entry?;
979 ensure(
980 entry.file_type()?.is_dir(),
981 "non-empty support layout requires separate compaction qualification",
982 )?;
983 validate_empty_directory(&entry.path(), depth + 1)?;
984 }
985 Ok(())
986}
987fn validate_operational(root: &Path) -> Result<()> {
988 ensure(
989 !root.join("forks").exists(),
990 "store forks require separate compaction qualification",
991 )?;
992 let manifest = root.join("manifest.json");
993 ensure(
994 std::fs::metadata(&manifest)?.len() <= 64 * 1024 * 1024,
995 "operational manifest exceeds budget",
996 )?;
997 let document: crate::serde_json::Value =
998 crate::serde_json::from_str(&std::fs::read_to_string(manifest)?)?;
999 fn check(value: &crate::serde_json::Value, depth: usize) -> Result<()> {
1000 use crate::serde_json::Value;
1001 ensure(depth < 32, "operational manifest exceeds nesting limit")?;
1002 match value {
1003 Value::Object(fields) => {
1004 for (key, value) in fields {
1005 if key == "source" || key == "fork_origin" {
1006 ensure(
1007 matches!(value, Value::Null),
1008 "external operational owners require separate qualification",
1009 )?;
1010 }
1011 if key == "path" {
1012 let path = Path::new(value.as_str().ok_or("non-string operational path")?);
1013 ensure(
1014 !path.is_absolute()
1015 && path
1016 .components()
1017 .all(|part| matches!(part, std::path::Component::Normal(_))),
1018 "operational path escapes its directory",
1019 )?;
1020 }
1021 check(value, depth + 1)?;
1022 }
1023 }
1024 Value::Array(values) => {
1025 for value in values {
1026 check(value, depth + 1)?;
1027 }
1028 }
1029 _ => {}
1030 }
1031 Ok(())
1032 }
1033 check(&document, 0)
1034}
1035fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1036 ensure(
1037 hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1038 "sidecar failed complete readback parity",
1039 )
1040}
1041fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1042 let mut hash = blake3::Hasher::new();
1043 let mut buffer = [0; 64 * 1024];
1044 loop {
1045 let count = input.read(&mut buffer)?;
1046 if count == 0 {
1047 break;
1048 }
1049 hash.update(&buffer[..count]);
1050 }
1051 Ok(hash.finalize())
1052}
1053fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1054 #[cfg(unix)]
1055 {
1056 use std::os::unix::fs::MetadataExt;
1057 Ok(Some(
1058 std::fs::metadata(path)?
1059 .blocks()
1060 .checked_mul(512)
1061 .ok_or("allocated byte count overflow")?,
1062 ))
1063 }
1064 #[cfg(not(unix))]
1065 {
1066 let _ = path;
1067 Ok(None)
1068 }
1069}
1070fn create_new(path: &Path) -> std::io::Result<File> {
1071 let mut options = OpenOptions::new();
1072 options.write(true).create_new(true);
1073 #[cfg(unix)]
1074 {
1075 use std::os::unix::fs::OpenOptionsExt;
1076 options.mode(0o600);
1077 }
1078 options.open(path)
1079}
1080fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1081 let mut file = create_new(path)?;
1082 file.write_all(bytes)?;
1083 file.sync_all()?;
1084 Ok(())
1085}
1086fn sync_directory(_path: &Path) -> Result<()> {
1087 #[cfg(unix)]
1088 File::open(_path)?.sync_all()?;
1089 Ok(())
1090}
1091fn ensure(condition: bool, message: &str) -> Result<()> {
1092 if condition {
1093 Ok(())
1094 } else {
1095 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1096 }
1097}
1098fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1099 Ok(u32::from_le_bytes(
1100 bytes
1101 .get(offset..offset + 4)
1102 .ok_or("truncated native integer")?
1103 .try_into()?,
1104 ))
1105}