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;
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_wal_recovery_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 WalRecord::PageOwnership { .. } | WalRecord::FullPageImage { .. } => {
133 pending_ownership = true
134 }
135 _ => {}
136 }
137 }
138 }
139 if !guard.pending_double_write
140 && !guard.header.checkpoint_in_progress
141 && !pending_ownership
142 && !pending_engine
143 {
144 salvage::verify_vault(&source, &guard)?;
145 }
146 let mut recovered_engine_xid = 0;
147 if pending_engine {
148 for entry in WalReader::open(&engine_wal)?.iter() {
149 match entry?.1 {
150 WalRecord::Begin { .. }
151 | WalRecord::Commit { .. }
152 | WalRecord::Rollback { .. }
153 | WalRecord::Checkpoint { .. } => {}
154 WalRecord::PageWrite { page_id, data, .. } => {
155 ensure(
156 page_id > 0
157 && !(DWB_ZONE_START_PAGE..FIRST_ALLOCATABLE_PAGE).contains(&page_id)
158 && u64::from(page_id) < guard.bytes.div_ceil(PAGE_SIZE as u64)
159 && data.len() == PAGE_SIZE,
160 "invalid engine WAL page",
161 )?;
162 let page = Page::from_slice(&data)?;
163 ensure(
164 page.page_id() == page_id,
165 "engine WAL page identity mismatch",
166 )?;
167 page.page_type()?;
168 }
172 _ => return Err("unsupported engine WAL record".into()),
173 }
174 }
175 }
176 std::fs::create_dir(destination)?;
177 #[cfg(unix)]
178 {
179 use std::os::unix::fs::PermissionsExt;
180 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
181 }
182 let stage = destination.join(".wal-replay");
183 std::fs::create_dir(&stage)?;
184 let name = source.file_name().ok_or("source filename is missing")?;
185 let staged_source = stage.join(name);
186 guard.copy_snapshot(&staged_source)?;
187 copy_sidecars(&source, &staged_source, true)?;
188 copy_physical_metadata_files(&source, &staged_source)?;
189 let preserved_audit = detach_recovery_audit_logs(&staged_source, &stage)?;
190 let config = crate::storage::unified::UnifiedStoreConfig {
191 resident_memory_bytes_max: (guard.header.version
192 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION)
193 .then_some(64 * 1024 * 1024),
194 ..Default::default()
195 };
196 if pending_engine {
197 let pager = Pager::open(
198 &staged_source,
199 PagerConfig {
200 disk_residency: config.resident_memory_bytes_max.is_some(),
201 ..PagerConfig::default()
202 },
203 )?;
204 let wal = f::engine_wal_path(&staged_source);
205 recovered_engine_xid = replay_engine_wal_on_copy(&pager, &wal)?;
206 let mut writer = crate::storage::wal::WalWriter::open(&wal)?;
207 writer.truncate()?;
208 writer.sync()?;
209 }
210 let probabilistic_deltas;
211 let mut has_vector_cache = false;
212 {
213 let store =
214 crate::storage::unified::UnifiedStore::open_with_config(&staged_source, config)?;
215 if pending_engine || guard.pending_double_write {
216 store.reconcile_physical_recovery()?;
217 }
218 if pending_unified || pending_engine || guard.pending_double_write {
219 let mut high = store
223 .startup_xid_high_water_mark()
224 .unwrap_or(0)
225 .max(recovered_engine_xid);
226 for name in store.list_collections() {
227 let manager = store
228 .get_collection(&name)
229 .ok_or("recovered collection disappeared")?;
230 let mut max_entity = 0u64;
231 let mut max_row = 0u64;
232 let mut max_sequence = 0u64;
233 let mut has_entity = false;
234 manager.try_for_each_entity(32 * 1024 * 1024, |entity| {
235 has_entity = true;
236 high = high.max(entity.xmin).max(entity.xmax);
237 max_entity = max_entity.max(entity.id.raw());
238 max_sequence = max_sequence.max(entity.sequence_id);
239 if let crate::storage::EntityKind::TableRow { row_id, .. } = &entity.kind {
240 max_row = max_row.max(*row_id);
241 }
242 true
243 })?;
244 if !has_entity {
245 continue;
246 }
247 let entity_next = max_entity
248 .checked_add(1)
249 .ok_or("recovered entity IDs are exhausted")?;
250 let row_next = max_row
251 .checked_add(1)
252 .ok_or("recovered row IDs are exhausted")?;
253 let sequence_next = max_sequence
254 .checked_add(1)
255 .ok_or("recovered sequence IDs are exhausted")?;
256 manager.restore_identity_counters(entity_next, row_next);
257 manager.restore_sequence_id_next(sequence_next);
258 store.register_entity_id(crate::storage::EntityId::new(max_entity));
259 }
260 ensure(
261 high < u64::MAX,
262 "recovered transaction high-water mark is exhausted",
263 )?;
264 store.remember_startup_xid_high_water_mark(high);
265 }
266 for (collection, records) in store.replayed_turbo_inserts.lock().iter() {
270 has_vector_cache = true;
271 ensure(
272 crate::runtime::vector_turbo_kind::is_turbo(&store, collection),
273 "WAL vector collection is not a turbo collection",
274 )?;
275 for (id, vector) in records {
276 let entity = store
277 .try_get(
278 collection,
279 crate::storage::EntityId::new(*id),
280 32 * 1024 * 1024,
281 )?
282 .ok_or("WAL vector has no recovered entity")?;
283 let crate::storage::EntityData::Vector(data) = entity.data else {
284 return Err("WAL vector points to a non-vector entity".into());
285 };
286 ensure(
287 data.dense.len() == vector.len()
288 && data
289 .dense
290 .iter()
291 .zip(vector)
292 .all(|(a, b)| a.to_bits() == b.to_bits()),
293 "WAL vector differs from recovered entity",
294 )?;
295 }
296 }
297 has_vector_cache |= verify_turbo_snapshots(&staged_source, &store)?;
298 probabilistic_deltas = store.take_replayed_probabilistic_deltas();
299 store.persist()?;
300 }
301 if !probabilistic_deltas.is_empty() {
302 let options = crate::RedDBOptions::persistent(&staged_source)
303 .with_memory_budget(64 * 1024 * 1024)
304 .with_bounded_disk_residency(
305 guard.header.version >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
306 );
307 let runtime = crate::RedDBRuntime::with_options(options)?;
308 runtime.persist_offline_probabilistic_recovery(probabilistic_deltas)?;
309 runtime.checkpoint()?;
310 runtime.graceful_shutdown(false)?;
311 }
312 if has_vector_cache {
313 scrub::verify_recovery_vector_pages(&staged_source)?;
314 }
315 let validated_dir = stage.join("validated");
316 let report = compact_paged(&staged_source, &validated_dir)?;
317 let verified = scrub_paged(&validated_dir.join(name))?;
318 ensure(
319 verified.assets_verified
320 && verified.trees.findings.is_empty()
321 && verified.unclassified_pages == 0,
322 "replayed store failed scrub",
323 )?;
324 if let Some(audit) = preserved_audit {
325 restore_recovery_audit_logs(&audit, &validated_dir.join(name))?;
326 }
327 let final_path = destination.join(name);
328 copy_sidecars(&validated_dir.join(name), &final_path, false)?;
329 copy_physical_metadata_files(&validated_dir.join(name), &final_path)?;
330 write_new(
331 &destination.join("compaction.json"),
332 report.machine_json().as_bytes(),
333 )?;
334 sync_directory(destination)?;
335 std::fs::hard_link(validated_dir.join(name), &final_path)?;
336 sync_directory(destination)?;
337 std::fs::remove_file(validated_dir.join(name))?;
340 sync_directory(&validated_dir)?;
341 Ok(report)
342}
343
344fn verify_wal_recovery_action(bytes: &[u8]) -> Result<()> {
345 f::decode_store_wal_action_frame(bytes)?;
346 Ok(())
347}
348
349fn verify_turbo_snapshots(
350 path: &Path,
351 store: &crate::storage::unified::UnifiedStore,
352) -> Result<bool> {
353 let snapshots = f::support_dir_for(path).join("snapshots");
354 if !snapshots.exists() {
355 return Ok(false);
356 }
357 let mut found = false;
358 for entry in std::fs::read_dir(snapshots)? {
359 let entry = entry?;
360 if entry.file_type()?.is_dir() {
361 continue;
362 }
363 found = true;
364 let path = entry.path();
365 let name = path
366 .file_stem()
367 .and_then(|name| name.to_str())
368 .ok_or("invalid turbo snapshot name")?;
369 ensure(
370 crate::runtime::vector_turbo_kind::is_turbo(store, name),
371 "snapshot has no turbo collection",
372 )?;
373 let length = entry.metadata()?.len();
374 ensure(
375 length <= 64 * 1024 * 1024,
376 "turbo snapshot exceeds recovery budget",
377 )?;
378 let mut header = [0u8; f::TURBOQUANT_SNAPSHOT_HEADER_BYTES];
379 File::open(&path)?.read_exact(&mut header)?;
380 let dim = u32::from_le_bytes(header[12..16].try_into()?);
381 let count = u64::from_le_bytes(header[32..40].try_into()?);
382 let record_size = u64::from(dim)
383 .checked_mul(4)
384 .and_then(|v| v.checked_add(12))
385 .ok_or("snapshot dimension overflow")?;
386 let expected = count
387 .checked_mul(record_size)
388 .and_then(|v| v.checked_add(header.len() as u64))
389 .ok_or("snapshot length overflow")?;
390 ensure(expected == length, "invalid turbo snapshot length")?;
391 let payload = f::read_turboquant_snapshot(
392 &path,
393 dim,
394 crate::runtime::vector_turbo_kind::TURBO_CODEC_SEED,
395 )?;
396 for (id, vector) in payload.vectors {
397 let entity = store
398 .try_get(name, crate::storage::EntityId::new(id), 32 * 1024 * 1024)?
399 .ok_or("snapshot vector has no recovered entity")?;
400 let crate::storage::EntityData::Vector(data) = entity.data else {
401 return Err("snapshot points to a non-vector entity".into());
402 };
403 ensure(
404 data.dense.len() == vector.len()
405 && data
406 .dense
407 .iter()
408 .zip(&vector)
409 .all(|(a, b)| a.to_bits() == b.to_bits()),
410 "snapshot vector differs from recovered entity",
411 )?;
412 }
413 std::fs::remove_file(path)?;
416 }
417 Ok(found)
418}
419
420fn wal_has_records(path: &Path) -> Result<bool> {
421 match std::fs::metadata(path) {
422 Ok(metadata) => Ok(metadata.len() > f::WAL_FILE_HEADER_BYTES as u64),
423 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
424 Err(error) => Err(error.into()),
425 }
426}
427
428fn replay_engine_wal_on_copy(pager: &Pager, path: &Path) -> Result<u64> {
429 use crate::storage::wal::{WalReader, WalRecord};
430 let mut transactions = std::collections::HashMap::new();
431 let mut last_lsn = 0;
432 let mut highest_tx = 0u64;
433 let header = pager.header()?;
434 let mut target_seen = !header.checkpoint_in_progress;
435 for item in WalReader::open(path)?.iter() {
436 let (lsn, record) = item?;
437 last_lsn = lsn;
438 target_seen |= lsn == header.checkpoint_target_lsn;
439 match record {
440 WalRecord::Begin { tx_id } => {
441 highest_tx = highest_tx.max(tx_id);
442 transactions.insert(tx_id, false);
443 }
444 WalRecord::Commit { tx_id } => {
445 highest_tx = highest_tx.max(tx_id);
446 transactions.insert(tx_id, true);
447 }
448 WalRecord::Rollback { tx_id } => {
449 highest_tx = highest_tx.max(tx_id);
450 transactions.remove(&tx_id);
451 }
452 WalRecord::PageWrite { tx_id, .. } => highest_tx = highest_tx.max(tx_id),
453 _ => {}
454 }
455 }
456 ensure(
457 target_seen,
458 "engine WAL does not contain the interrupted checkpoint target",
459 )?;
460 let mut latest = std::collections::HashMap::new();
462 for item in WalReader::open(path)?.iter() {
463 let (lsn, record) = item?;
464 if let WalRecord::PageWrite { tx_id, page_id, .. } = record {
465 if transactions.get(&tx_id).copied().unwrap_or(false) {
466 latest.insert(page_id, lsn);
467 }
468 }
469 }
470 ensure(
471 !header.checkpoint_in_progress || !latest.is_empty(),
472 "interrupted checkpoint has no committed page images",
473 )?;
474 if !latest.is_empty() {
475 pager.set_checkpoint_in_progress(true, last_lsn)?;
476 for item in WalReader::open(path)?.iter() {
477 let (lsn, record) = item?;
478 if let WalRecord::PageWrite { page_id, data, .. } = record {
479 if latest.get(&page_id) == Some(&lsn) {
480 pager.write_page(page_id, Page::from_slice(&data)?)?;
481 }
482 }
483 }
484 pager.sync()?;
485 pager.complete_checkpoint(last_lsn)?;
486 }
487 Ok(highest_tx)
488}
489
490fn copy_physical_metadata_files(source: &Path, destination: &Path) -> Result<()> {
491 for (old, new) in [
492 (
493 f::physical_metadata_binary_path(source),
494 f::physical_metadata_binary_path(destination),
495 ),
496 (
497 f::physical_metadata_json_path(source),
498 f::physical_metadata_json_path(destination),
499 ),
500 ] {
501 match std::fs::symlink_metadata(&old) {
502 Ok(stat) => {
503 ensure(
504 stat.is_file() && stat.len() <= 64 * 1024 * 1024,
505 "invalid physical metadata sidecar",
506 )?;
507 let mut input = File::open(&old)?;
508 let mut output = create_new(&new)?;
509 std::io::copy(&mut input, &mut output)?;
510 output.sync_all()?;
511 drop(output);
512 verify_file_copy(&old, &new)?;
513 }
514 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
515 Err(error) => return Err(error.into()),
516 }
517 }
518 Ok(())
519}
520
521fn compact_paged_inner(
522 source: &Path,
523 destination: &Path,
524 salvage: Option<&PagedIndexSalvageReport>,
525 lost_collections: Option<&PagedCollectionSalvageReport>,
526 retain_canonical_system_config: bool,
527) -> Result<CompactionReport> {
528 let source_path = source.canonicalize()?;
529 let parent = destination
530 .parent()
531 .filter(|parent| !parent.as_os_str().is_empty())
532 .unwrap_or_else(|| Path::new("."))
533 .canonicalize()?;
534 for source_directory in [
535 f::support_dir_for(&source_path),
536 source_path.with_file_name(format!("{}.ops", f::data_file_name(&source_path))),
537 ] {
538 if source_directory.exists() {
539 ensure(
540 !parent.starts_with(source_directory.canonicalize()?),
541 "destination is inside source storage",
542 )?;
543 }
544 }
545 let source = Arc::new(Source::open(&source_path)?);
546 let mut catalog = Catalog::read(&source)?;
547 if retain_canonical_system_config {
548 catalog.retain_canonical_system_config()?;
549 }
550 ensure(
551 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
552 || catalog.resume.is_some(),
553 "prepared disk-resident file has no resume catalog",
554 )?;
555 if let Some(salvage) = salvage {
556 let resume = catalog
557 .resume
558 .as_mut()
559 .ok_or("index recovery requires a resume catalog")?;
560 for finding in &salvage.indexes {
561 let state = resume
562 .collections
563 .get_mut(&finding.collection)
564 .ok_or("index collection missing from resume")?;
565 let index = state
566 .secondary_indices
567 .remove(&finding.index)
568 .ok_or("index missing from resume")?;
569 ensure(
570 index.root_page == finding.root_page,
571 "index root changed during salvage",
572 )?;
573 }
574 }
575 if let Some(losses) = lost_collections {
576 ensure(
577 catalog.resume.is_none() && !catalog.has_cross_references(),
578 "collection recovery requires a reference-free legacy catalog",
579 )?;
580 for loss in &losses.collections {
581 let root = catalog
582 .roots
583 .iter_mut()
584 .find(|root| root.collection == loss.collection)
585 .ok_or("collection missing from catalog")?;
586 ensure(
587 root.root_page == loss.root_page,
588 "collection root changed during salvage",
589 )?;
590 if loss.skipped_pages == [loss.root_page] {
591 root.root_page = 0;
592 }
593 }
594 }
595 let name = source_path
596 .file_name()
597 .ok_or("source filename is missing")?;
598 std::fs::create_dir(destination)?;
600 #[cfg(unix)]
601 {
602 use std::os::unix::fs::PermissionsExt;
603 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
604 }
605 let output = destination.join(name);
606 let temporary = destination.join(".compacting.rdb");
607 ensure(output != temporary, "reserved compaction source filename")?;
608 let target = Arc::new(Pager::open(
609 &temporary,
610 PagerConfig {
611 cache_size: 1024,
612 disk_residency: source.header.version
613 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
614 ..PagerConfig::default()
615 },
616 )?);
617 let mut roots = BTreeMap::new();
618 let mut recovered_trees = BTreeMap::new();
619 let mut records = 0;
620 let mut repaired_leaf_backlinks = 0;
621 for collection in &catalog.roots {
622 let loss = lost_collections.and_then(|report| {
623 report
624 .collections
625 .iter()
626 .find(|loss| loss.collection == collection.collection && collection.root_page != 0)
627 });
628 let count = if let Some(loss) = loss {
629 let (count, hash) = rewrite_recoverable_tree(&source, &target, loss, &mut roots)?;
630 recovered_trees.insert(loss.root_page, (count, hash));
631 count
632 } else {
633 rewrite_tree(
634 &source,
635 &target,
636 collection.root_page,
637 &mut roots,
638 &mut repaired_leaf_backlinks,
639 )?
640 };
641 if let Some(resume) = &catalog.resume {
642 ensure(
643 resume.collections[&collection.collection].entities == count,
644 "collection count disagrees with resume",
645 )?;
646 }
647 records += count;
648 }
649 if let Some(resume) = &catalog.resume {
650 for state in resume.collections.values() {
651 if let Some(root) = state.key_index_root {
652 records += rewrite_tree(
653 &source,
654 &target,
655 root,
656 &mut roots,
657 &mut repaired_leaf_backlinks,
658 )?;
659 }
660 for index in state.secondary_indices.values() {
661 records += rewrite_tree(
662 &source,
663 &target,
664 index.root_page,
665 &mut roots,
666 &mut repaired_leaf_backlinks,
667 )?;
668 }
669 }
670 }
671 let mut assets = assets::Assets::new(&source, &target);
672 assets.vault()?;
673 let physical = assets.physical()?;
674 assets.metadata(&source_path, Some(&output))?;
675 if salvage.is_some() || lost_collections.is_some() {
676 if let Some(salvage) = salvage {
677 for index in &salvage.indexes {
678 if index.root_page != 0 {
679 match source.raw_page(index.root_page, true) {
680 Ok(_) => {
681 let tree = BTree::with_root(Arc::clone(&source), index.root_page);
682 let skipped = tree.visit_recoverable_leaves(
683 |id| {
684 source.claim(id).map_err(|error| {
685 reddb_engine::storage::btree::BTreeError::Corrupted(
686 error.to_string(),
687 )
688 })
689 },
690 |_, _| Ok(()),
691 )?;
692 ensure(
693 !skipped.is_empty(),
694 "index damage is not a verified leaf checksum failure",
695 )?;
696 }
697 Err(error) => {
698 ensure(
699 matches!(
700 error.downcast_ref::<reddb_engine::storage::page::PageError>(),
701 Some(
702 reddb_engine::storage::page::PageError::ChecksumMismatch { .. }
703 )
704 ),
705 "index root failure is not a checksum mismatch",
706 )?;
707 ensure(
708 source.raw_page(index.root_page, false)?.page_type()?
709 == PageType::BTreeLeaf,
710 "damaged interior index root cannot be recovered",
711 )?;
712 source.claim(index.root_page)?;
713 }
714 }
715 }
716 }
717 }
718 if let Some(losses) = lost_collections {
719 for collection in &losses.collections {
720 if collection.skipped_pages == [collection.root_page] {
721 source.claim(collection.root_page)?;
722 }
723 }
724 }
725 ensure(
726 source.unclaimed_pages() == 0,
727 "unclassified source pages require separate recovery verification",
728 )?;
729 }
730 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
731 target.update_physical_header(physical)?;
732 target.fence_file_format_version(source.header.version)?;
733 target.sync()?;
734 for _ in 0..2 {
736 target.commit_superblock(|slot| {
737 let mut header = f::decode_database_header(slot).map_err(|error| {
738 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
739 })?;
740 header.schema_version = source.header.schema_version;
741 header.checkpoint_lsn = source.header.checkpoint_lsn;
742 f::encode_database_header(slot, &header).map_err(|error| {
743 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
744 })
745 })?;
746 }
747 if lost_collections.is_some() {
748 for _ in 0..2 {
751 target.commit_superblock(|slot| {
752 f::set_database_header_version(
753 slot,
754 f::file_format::PAGE_RECOVERY_READ_ONLY_FILE_VERSION,
755 )
756 .map_err(|error| {
757 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
758 })
759 })?;
760 }
761 }
762 let assets_verification = assets.into_verification();
763 drop(target);
764 let reopened = Arc::new(Source::open(&temporary)?);
765 if salvage.is_some() || lost_collections.is_some() {
766 salvage::verify_vault(&temporary, &reopened)?;
767 }
768 ensure(
769 reopened.header.physical == physical,
770 "rewritten physical header failed parity",
771 )?;
772 let rewritten_catalog = Catalog::read(&reopened)?;
773 ensure(
774 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
775 "rewritten catalog differs from source",
776 )?;
777 for (&old, &new) in &roots {
778 if let Some((count, hash)) = recovered_trees.get(&old) {
779 verify_recovered_tree(&reopened, new, *count, *hash)?;
780 } else {
781 verify_tree(&source, &reopened, old, new)?;
782 }
783 }
784 for (id, expected, checksum) in assets_verification {
785 let page = reopened.raw_page(id, checksum)?;
786 if id != 2 {
787 reopened.claim(id)?;
788 }
789 ensure(
790 blake3::hash(page.content()) == expected,
791 "native asset failed readback parity",
792 )?;
793 }
794 let report = CompactionReport {
795 source_bytes: source.bytes,
796 destination_bytes: reopened.bytes,
797 source_allocated_bytes: allocated_bytes(&source_path)?,
798 destination_allocated_bytes: allocated_bytes(&temporary)?,
799 records,
800 trees: roots.len(),
801 repaired_leaf_backlinks,
802 };
803 drop(reopened);
804 copy_sidecars(&source_path, &output, false)?;
805 if retain_canonical_system_config {
806 let operational_path = output.with_file_name(format!("{}.ops", f::data_file_name(&output)));
807 if operational_path.exists() {
808 crate::storage::operational_manifest::OperationalManifest::for_db_path(&output)
809 .reconcile_collection_collisions(&[("red.config", "red_config", "red.config")])?;
810 }
811 }
812 write_new(
813 &destination.join("compaction.json"),
814 report.machine_json().as_bytes(),
815 )?;
816 if let Some(salvage) = salvage {
817 write_new(
818 &destination.join("salvage.json"),
819 salvage.machine_json().as_bytes(),
820 )?;
821 }
822 if let Some(losses) = lost_collections {
823 write_new(
824 &destination.join("salvage.json"),
825 losses.machine_json().as_bytes(),
826 )?;
827 }
828 sync_directory(destination)?;
829 std::fs::hard_link(&temporary, &output)?;
831 sync_directory(destination)?;
832 std::fs::remove_file(&temporary)?;
833 sync_directory(destination)?;
834 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
835 sync_directory(parent)?;
836 }
837 Ok(report)
838}
839fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
840 roots.values().map(|root| (*root, *root)).collect()
841}
842
843fn hash_record(hasher: &mut blake3::Hasher, key: &[u8], value: &[u8]) {
844 hasher.update(&(key.len() as u64).to_le_bytes());
845 hasher.update(key);
846 hasher.update(&(value.len() as u64).to_le_bytes());
847 hasher.update(value);
848}
849
850fn rewrite_recoverable_tree(
851 source: &Arc<Source>,
852 target: &Arc<Pager>,
853 loss: &PagedCollectionLoss,
854 roots: &mut BTreeMap<u32, u32>,
855) -> Result<(u64, blake3::Hash)> {
856 ensure(
857 !roots.contains_key(&loss.root_page),
858 "duplicate live tree owner",
859 )?;
860 let original = BTree::with_root(Arc::clone(source), loss.root_page);
861 let destination = BTree::new(Arc::clone(target));
862 let mut hash = blake3::Hasher::new();
863 let mut count = 0u64;
864 let mut batch = Vec::new();
865 let mut size = 0usize;
866 let skipped = original.visit_recoverable_leaves(
867 |id| {
868 source.claim(id).map_err(|error| {
869 reddb_engine::storage::btree::BTreeError::Corrupted(error.to_string())
870 })
871 },
872 |key, value| {
873 hash_record(&mut hash, &key, &value);
874 size += key.len() + value.len();
875 batch.push((key, value));
876 count += 1;
877 if size >= 1024 * 1024 || batch.len() >= 512 {
878 destination.bulk_insert_sorted(&batch)?;
879 batch.clear();
880 size = 0;
881 }
882 Ok(())
883 },
884 )?;
885 ensure(
886 skipped == loss.skipped_pages,
887 "damaged pages changed during recovery",
888 )?;
889 destination.bulk_insert_sorted(&batch)?;
890 roots.insert(loss.root_page, destination.root_page_id());
891 Ok((count, hash.finalize()))
892}
893
894fn verify_recovered_tree(
895 source: &Arc<Source>,
896 root: u32,
897 count: u64,
898 expected: blake3::Hash,
899) -> Result<()> {
900 let tree = BTree::with_root(Arc::clone(source), root);
901 let (owned, _) = inventory_tree(source, &tree, false)?;
902 ensure(owned == count, "recovered tree record count differs")?;
903 let mut cursor = tree.cursor_first()?;
904 let mut hash = blake3::Hasher::new();
905 let mut actual = 0u64;
906 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
907 hash_record(&mut hash, &key, &value);
908 actual += 1;
909 ensure(actual <= count, "recovered tree has extra records")?;
910 }
911 ensure(
912 actual == count && hash.finalize() == expected,
913 "recovered tree failed record parity",
914 )
915}
916
917fn rewrite_tree(
918 source: &Arc<Source>,
919 target: &Arc<Pager>,
920 root: u32,
921 roots: &mut BTreeMap<u32, u32>,
922 repaired_leaf_backlinks: &mut u64,
923) -> Result<u64> {
924 if root == 0 {
925 return Ok(0);
926 }
927 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
928 let tree = BTree::with_root(Arc::clone(source), root);
929 let (expected, repairs) = inventory_tree(source, &tree, true)?;
930 *repaired_leaf_backlinks += repairs;
931 let destination = BTree::new(Arc::clone(target));
932 let mut cursor = tree.cursor_first()?;
933 let mut count = 0;
934 let mut previous: Option<Vec<u8>> = None;
935 let mut batch = Vec::new();
936 let mut bytes = 0;
937 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
938 ensure(
939 previous.as_ref().is_none_or(|old| old < &key),
940 "source tree keys are not strictly ordered",
941 )?;
942 previous = Some(key.clone());
943 bytes += key.len() + value.len();
944 batch.push((key, value));
945 count += 1;
946 ensure(
947 count <= expected,
948 "tree cursor exceeds structural inventory",
949 )?;
950 if bytes >= 1024 * 1024 || batch.len() >= 512 {
951 destination.bulk_insert_sorted(&batch)?;
952 batch.clear();
953 bytes = 0;
954 }
955 }
956 destination.bulk_insert_sorted(&batch)?;
957 ensure(count == expected, "tree cursor omits owned records")?;
958 roots.insert(root, destination.root_page_id());
959 Ok(count)
960}
961fn inventory_tree(
965 source: &Source,
966 tree: &BTree<Source>,
967 allow_backlinks: bool,
968) -> Result<(u64, u64)> {
969 let pages = tree.retired_pages()?;
970 let mut leaves = Vec::new();
971 let mut expected = 0u64;
972 for id in pages {
973 source.claim(id)?;
974 let page = source.raw_page(id, true)?;
975 if page.page_type()? == PageType::BTreeLeaf {
976 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
977 expected += u64::from(page.cell_count());
978 }
979 }
980 leaves.reverse();
983 let mut repaired = 0;
984 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
985 ensure(
986 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
987 &format!(
988 "broken forward leaf chain at page {id}, root {}",
989 tree.root_page_id()
990 ),
991 )?;
992 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
993 ensure(
994 allow_backlinks,
995 &format!("broken backward leaf chain at page {id}"),
996 )?;
997 repaired += 1;
998 }
999 }
1000 Ok((expected, repaired))
1001}
1002fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
1003 let old = BTree::with_root(Arc::clone(source), old);
1004 let new = BTree::with_root(Arc::clone(destination), new);
1005 inventory_tree(destination, &new, false)?;
1006 let mut left = old.cursor_first()?;
1007 let mut right = new.cursor_first()?;
1008 loop {
1009 let expected = left.next_with_budget(32 * 1024 * 1024)?;
1010 let actual = right.next_with_budget(32 * 1024 * 1024)?;
1011 ensure(
1012 expected == actual,
1013 "compacted tree failed complete key/value parity",
1014 )?;
1015 if expected.is_none() {
1016 return Ok(());
1017 }
1018 }
1019}
1020fn copy_sidecars(source: &Path, destination: &Path, recovery: bool) -> Result<()> {
1021 for (old, new) in [
1024 (
1025 f::unified_wal_path(source),
1026 f::unified_wal_path(destination),
1027 ),
1028 (f::engine_wal_path(source), f::engine_wal_path(destination)),
1029 (
1030 f::logical_wal_path(source),
1031 f::logical_wal_path(destination),
1032 ),
1033 (
1034 f::legacy_logical_slots_path(source),
1035 f::legacy_logical_slots_path(destination),
1036 ),
1037 ] {
1038 match File::open(&old) {
1039 Ok(_) => copy_file_verified(&old, &new)?,
1040 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1041 Err(error) => return Err(error.into()),
1042 }
1043 }
1044 copy_audit_log_family(source, destination)?;
1045 let support = f::support_dir_for(source);
1046 if support.exists() {
1047 if recovery {
1048 validate_recovery_support(&support, 0, false)?;
1049 } else {
1050 validate_empty_directory(&support, 0)?;
1051 }
1052 copy_directory(&support, &f::support_dir_for(destination), 0)?;
1053 }
1054 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
1055 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
1056 if old.exists() {
1057 f::OperationalManifest::for_db_path(source).verify_owned_for_copy()?;
1058 copy_directory(&old, &new, 0)?;
1059 }
1060 Ok(())
1061}
1062
1063fn detach_recovery_audit_logs(source: &Path, stage: &Path) -> Result<Option<PathBuf>> {
1064 let family = audit_log_family(source)?;
1065 if family.is_empty() {
1066 return Ok(None);
1067 }
1068 let preserved = stage.join("preserved-audit");
1069 std::fs::create_dir(&preserved)?;
1070 for audit in family {
1071 let name = audit.file_name().ok_or("audit log filename is missing")?;
1072 std::fs::rename(&audit, preserved.join(name))?;
1073 }
1074 sync_directory(&preserved)?;
1075 sync_directory(stage)?;
1076 Ok(Some(preserved))
1077}
1078
1079fn restore_recovery_audit_logs(preserved: &Path, destination: &Path) -> Result<()> {
1080 let parent = destination
1081 .parent()
1082 .ok_or("destination parent is missing")?;
1083 for entry in std::fs::read_dir(preserved)? {
1084 let entry = entry?;
1085 ensure(
1086 entry.file_type()?.is_file(),
1087 "preserved audit log must be a regular file",
1088 )?;
1089 copy_file_verified(&entry.path(), &parent.join(entry.file_name()))?;
1090 }
1091 sync_directory(parent)
1092}
1093
1094fn copy_file_verified(source: &Path, destination: &Path) -> Result<()> {
1095 let mut input = File::open(source)?;
1096 let mut output = create_new(destination)?;
1097 std::io::copy(&mut input, &mut output)?;
1098 output.sync_all()?;
1099 verify_file_copy(source, destination)
1100}
1101
1102fn audit_log_family(data_path: &Path) -> Result<Vec<PathBuf>> {
1103 let active = f::legacy_audit_log_path(data_path);
1104 let parent = active.parent().ok_or("audit log parent is missing")?;
1105 let mut rotated = Vec::new();
1106 for entry in std::fs::read_dir(parent)? {
1107 let entry = entry?;
1108 let name = entry.file_name();
1109 let Some(name) = name.to_str() else {
1110 continue;
1111 };
1112 let Some(timestamp) = f::parse_audit_log_rotated_timestamp(&active, name) else {
1113 continue;
1114 };
1115 ensure(
1116 entry.file_type()?.is_file(),
1117 "rotated audit log must be a regular file",
1118 )?;
1119 rotated.push((timestamp, entry.path()));
1120 }
1121 rotated.sort_by_key(|(timestamp, _)| *timestamp);
1122 for pair in rotated.windows(2) {
1123 ensure(
1124 pair[0].0 != pair[1].0,
1125 "duplicate rotated audit log timestamp",
1126 )?;
1127 }
1128 let mut family: Vec<PathBuf> = rotated.into_iter().map(|(_, path)| path).collect();
1129 match std::fs::symlink_metadata(&active) {
1130 Ok(metadata) => {
1131 ensure(metadata.is_file(), "audit log must be a regular file")?;
1132 family.push(active);
1133 }
1134 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1135 Err(error) => return Err(error.into()),
1136 }
1137 Ok(family)
1138}
1139
1140fn copy_audit_log_family(source: &Path, destination: &Path) -> Result<()> {
1141 let destination_parent = destination
1142 .parent()
1143 .ok_or("audit log destination parent is missing")?;
1144 for old in audit_log_family(source)? {
1145 let name = old.file_name().ok_or("audit log filename is missing")?;
1146 copy_file_verified(&old, &destination_parent.join(name))?;
1147 }
1148 Ok(())
1149}
1150
1151pub(super) fn verify_audit_log_family(data_path: &Path) -> Result<()> {
1152 let mut previous = None;
1153 let mut has_events = false;
1154 for path in audit_log_family(data_path)? {
1155 let input = File::open(&path)?;
1156 if path
1157 .extension()
1158 .is_some_and(|extension| extension == f::layout::AUDIT_LOG_ROTATED_COMPRESSED_EXTENSION)
1159 {
1160 let decoder = zstd::stream::read::Decoder::new(input)?;
1161 (previous, has_events) = verify_audit_log_chain(decoder, previous, has_events)?;
1162 } else {
1163 (previous, has_events) = verify_audit_log_chain(input, previous, has_events)?;
1164 }
1165 }
1166 Ok(())
1167}
1168
1169fn verify_audit_log_chain(
1170 input: impl Read,
1171 mut previous: Option<String>,
1172 mut has_events: bool,
1173) -> Result<(Option<String>, bool)> {
1174 const MAX_EVENT_BYTES: usize = 1024 * 1024;
1175 let mut reader = BufReader::new(input);
1176 let mut line = Vec::new();
1177 loop {
1178 line.clear();
1179 loop {
1180 let buffer = reader.fill_buf()?;
1181 if buffer.is_empty() {
1182 if line.is_empty() {
1183 return Ok((previous, has_events));
1184 }
1185 break;
1186 }
1187 let count = buffer
1188 .iter()
1189 .position(|byte| *byte == b'\n')
1190 .map_or(buffer.len(), |position| position + 1);
1191 ensure(
1192 line.len().saturating_add(count) <= MAX_EVENT_BYTES,
1193 "audit log event exceeds recovery verification budget",
1194 )?;
1195 line.extend_from_slice(&buffer[..count]);
1196 let ended = buffer[count - 1] == b'\n';
1197 reader.consume(count);
1198 if ended {
1199 break;
1200 }
1201 }
1202 if line.last() == Some(&b'\n') {
1203 line.pop();
1204 }
1205 if line.last() == Some(&b'\r') {
1206 line.pop();
1207 }
1208 ensure(!line.is_empty(), "audit log contains an empty event")?;
1209 let value: crate::serde_json::Value = crate::serde_json::from_slice(&line)?;
1210 ensure(
1211 value.as_object().is_some(),
1212 "audit log event must be a JSON object",
1213 )?;
1214 let declared = match value.get("prev_hash") {
1215 Some(value) => Some(
1216 value
1217 .as_str()
1218 .ok_or("audit log prev_hash must be a string")?,
1219 ),
1220 None => None,
1221 };
1222 if has_events {
1223 ensure(
1224 declared == previous.as_deref(),
1225 "audit log hash chain verification failed",
1226 )?;
1227 } else if let Some(anchor) = declared {
1228 ensure(
1229 anchor.len() == 64 && anchor.bytes().all(|byte| byte.is_ascii_hexdigit()),
1230 "audit log initial prev_hash is invalid",
1231 )?;
1232 }
1233 previous = Some(crate::utils::to_hex(&crate::crypto::sha256(&line)));
1234 has_events = true;
1235 }
1236}
1237
1238fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
1239 ensure(depth < 32, "operational directory nesting exceeds limit")?;
1240 ensure(
1241 std::fs::symlink_metadata(source)?.is_dir(),
1242 "operational directory must not be a symlink",
1243 )?;
1244 std::fs::create_dir(destination)?;
1245 for entry in std::fs::read_dir(source)? {
1246 let entry = entry?;
1247 let kind = entry.file_type()?;
1248 let output = destination.join(entry.file_name());
1249 if kind.is_dir() {
1250 copy_directory(&entry.path(), &output, depth + 1)?;
1251 } else {
1252 ensure(
1253 kind.is_file(),
1254 "operational directory contains a symlink or special file",
1255 )?;
1256 let mut input = File::open(entry.path())?;
1257 let mut target = create_new(&output)?;
1258 std::io::copy(&mut input, &mut target)?;
1259 target.sync_all()?;
1260 verify_file_copy(&entry.path(), &output)?;
1261 }
1262 }
1263 sync_directory(destination)
1264}
1265fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
1266 ensure(depth < 32, "support directory nesting exceeds limit")?;
1267 ensure(
1268 std::fs::symlink_metadata(path)?.is_dir(),
1269 "support directory must not be a symlink",
1270 )?;
1271 for entry in std::fs::read_dir(path)? {
1272 let entry = entry?;
1273 ensure(
1274 entry.file_type()?.is_dir(),
1275 "non-empty support layout requires separate compaction qualification",
1276 )?;
1277 validate_empty_directory(&entry.path(), depth + 1)?;
1278 }
1279 Ok(())
1280}
1281fn validate_recovery_support(path: &Path, depth: usize, snapshots: bool) -> Result<()> {
1282 ensure(depth < 32, "support directory nesting exceeds limit")?;
1283 ensure(
1284 std::fs::symlink_metadata(path)?.is_dir(),
1285 "support directory must not be a symlink",
1286 )?;
1287 for entry in std::fs::read_dir(path)? {
1288 let entry = entry?;
1289 let kind = entry.file_type()?;
1290 if kind.is_dir() {
1291 validate_recovery_support(
1292 &entry.path(),
1293 depth + 1,
1294 depth == 0 && entry.file_name() == "snapshots",
1295 )?;
1296 } else {
1297 ensure(
1298 snapshots
1299 && kind.is_file()
1300 && entry.path().extension().is_some_and(|ext| ext == "tv"),
1301 "unsupported recovery support file",
1302 )?;
1303 }
1304 }
1305 Ok(())
1306}
1307
1308fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
1309 ensure(
1310 hash_reader(&mut File::open(source)?)? == hash_reader(&mut File::open(destination)?)?,
1311 "sidecar failed complete readback parity",
1312 )
1313}
1314fn hash_reader(input: &mut impl Read) -> Result<blake3::Hash> {
1315 let mut hash = blake3::Hasher::new();
1316 let mut buffer = [0; 64 * 1024];
1317 loop {
1318 let count = input.read(&mut buffer)?;
1319 if count == 0 {
1320 break;
1321 }
1322 hash.update(&buffer[..count]);
1323 }
1324 Ok(hash.finalize())
1325}
1326fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
1327 #[cfg(unix)]
1328 {
1329 use std::os::unix::fs::MetadataExt;
1330 Ok(Some(
1331 std::fs::metadata(path)?
1332 .blocks()
1333 .checked_mul(512)
1334 .ok_or("allocated byte count overflow")?,
1335 ))
1336 }
1337 #[cfg(not(unix))]
1338 {
1339 let _ = path;
1340 Ok(None)
1341 }
1342}
1343fn create_new(path: &Path) -> std::io::Result<File> {
1344 let mut options = OpenOptions::new();
1345 options.write(true).create_new(true);
1346 #[cfg(unix)]
1347 {
1348 use std::os::unix::fs::OpenOptionsExt;
1349 options.mode(0o600);
1350 }
1351 options.open(path)
1352}
1353fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1354 let mut file = create_new(path)?;
1355 file.write_all(bytes)?;
1356 file.sync_all()?;
1357 Ok(())
1358}
1359fn sync_directory(_path: &Path) -> Result<()> {
1360 #[cfg(unix)]
1361 File::open(_path)?.sync_all()?;
1362 Ok(())
1363}
1364fn ensure(condition: bool, message: &str) -> Result<()> {
1365 if condition {
1366 Ok(())
1367 } else {
1368 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
1369 }
1370}
1371fn number(bytes: &[u8], offset: usize) -> Result<u32> {
1372 Ok(u32::from_le_bytes(
1373 bytes
1374 .get(offset..offset + 4)
1375 .ok_or("truncated native integer")?
1376 .try_into()?,
1377 ))
1378}