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 scrub;
21mod source;
22use catalog::Catalog;
23pub use scrub::{scrub_paged_trees, PagedTreeFinding, PagedTreeScrubReport};
24use source::Source;
25type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
26
27#[derive(Debug)]
28pub struct CompactionReport {
29 pub source_bytes: u64,
30 pub destination_bytes: u64,
31 pub source_allocated_bytes: Option<u64>,
32 pub destination_allocated_bytes: Option<u64>,
33 pub records: u64,
35 pub trees: usize,
36 pub repaired_leaf_backlinks: u64,
38}
39
40impl CompactionReport {
41 pub fn machine_json(&self) -> String {
42 format!(
43 r#"{{"source_bytes":{},"destination_bytes":{},"records":{},"trees":{},"repaired_leaf_backlinks":{},"source_allocated_bytes":{},"destination_allocated_bytes":{}}}"#,
44 self.source_bytes,
45 self.destination_bytes,
46 self.records,
47 self.trees,
48 self.repaired_leaf_backlinks,
49 self.source_allocated_bytes
50 .map_or_else(|| "null".into(), |n| n.to_string()),
51 self.destination_allocated_bytes
52 .map_or_else(|| "null".into(), |n| n.to_string())
53 )
54 }
55}
56
57pub fn compact_paged(source: &Path, destination: &Path) -> Result<CompactionReport> {
61 let source_path = source.canonicalize()?;
62 let source = Arc::new(Source::open(&source_path)?);
63 let catalog = Catalog::read(&source)?;
64 ensure(
65 source.header.version < f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
66 || catalog.resume.is_some(),
67 "prepared disk-resident file has no resume catalog",
68 )?;
69 let name = source_path
70 .file_name()
71 .ok_or("source filename is missing")?;
72 std::fs::create_dir(destination)?;
74 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o700))?;
78 }
79 let output = destination.join(name);
80 let temporary = destination.join(".compacting.rdb");
81 ensure(output != temporary, "reserved compaction source filename")?;
82 let target = Arc::new(Pager::open(
83 &temporary,
84 PagerConfig {
85 cache_size: 1024,
86 disk_residency: source.header.version
87 >= f::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION,
88 ..PagerConfig::default()
89 },
90 )?);
91 let mut roots = BTreeMap::new();
92 let mut records = 0;
93 let mut repaired_leaf_backlinks = 0;
94 for collection in &catalog.roots {
95 let count = rewrite_tree(
96 &source,
97 &target,
98 collection.root_page,
99 &mut roots,
100 &mut repaired_leaf_backlinks,
101 )?;
102 if let Some(resume) = &catalog.resume {
103 ensure(
104 resume.collections[&collection.collection].entities == count,
105 "collection count disagrees with resume",
106 )?;
107 }
108 records += count;
109 }
110 if let Some(resume) = &catalog.resume {
111 for state in resume.collections.values() {
112 if let Some(root) = state.key_index_root {
113 records += rewrite_tree(
114 &source,
115 &target,
116 root,
117 &mut roots,
118 &mut repaired_leaf_backlinks,
119 )?;
120 }
121 for index in state.secondary_indices.values() {
122 records += rewrite_tree(
123 &source,
124 &target,
125 index.root_page,
126 &mut roots,
127 &mut repaired_leaf_backlinks,
128 )?;
129 }
130 }
131 }
132 let mut assets = assets::Assets::new(&source, &target);
133 assets.vault()?;
134 let physical = assets.physical()?;
135 assets.metadata(&source_path, &output)?;
136 catalog::write_catalog(&target, &catalog.encode(&roots)?)?;
137 target.update_physical_header(physical)?;
138 target.fence_file_format_version(source.header.version)?;
139 target.sync()?;
140 for _ in 0..2 {
142 target.commit_superblock(|slot| {
143 let mut header = f::decode_database_header(slot).map_err(|error| {
144 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
145 })?;
146 header.schema_version = source.header.schema_version;
147 header.checkpoint_lsn = source.header.checkpoint_lsn;
148 f::encode_database_header(slot, &header).map_err(|error| {
149 reddb_engine::storage::pager::PagerError::InvalidDatabase(error.to_string())
150 })
151 })?;
152 }
153 let assets_verification = assets.into_verification();
154 drop(target);
155 let reopened = Arc::new(Source::open(&temporary)?);
156 ensure(
157 reopened.header.physical == physical,
158 "rewritten physical header failed parity",
159 )?;
160 let rewritten_catalog = Catalog::read(&reopened)?;
161 ensure(
162 rewritten_catalog.encode(&identity_roots(&roots))? == catalog.encode(&roots)?,
163 "rewritten catalog differs from source",
164 )?;
165 for (&old, &new) in &roots {
166 verify_tree(&source, &reopened, old, new)?;
167 }
168 for (id, expected, checksum) in assets_verification {
169 let page = reopened.raw_page(id, checksum)?;
170 if id != 2 {
171 reopened.claim(id)?;
172 }
173 ensure(
174 blake3::hash(page.content()) == expected,
175 "native asset failed readback parity",
176 )?;
177 }
178 let report = CompactionReport {
179 source_bytes: source.bytes,
180 destination_bytes: reopened.bytes,
181 source_allocated_bytes: allocated_bytes(&source_path)?,
182 destination_allocated_bytes: allocated_bytes(&temporary)?,
183 records,
184 trees: roots.len(),
185 repaired_leaf_backlinks,
186 };
187 drop(reopened);
188 copy_sidecars(&source_path, &output)?;
189 write_new(
190 &destination.join("compaction.json"),
191 report.machine_json().as_bytes(),
192 )?;
193 sync_directory(destination)?;
194 std::fs::hard_link(&temporary, &output)?;
196 sync_directory(destination)?;
197 std::fs::remove_file(&temporary)?;
198 sync_directory(destination)?;
199 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
200 sync_directory(parent)?;
201 }
202 Ok(report)
203}
204fn identity_roots(roots: &BTreeMap<u32, u32>) -> BTreeMap<u32, u32> {
205 roots.values().map(|root| (*root, *root)).collect()
206}
207
208fn rewrite_tree(
209 source: &Arc<Source>,
210 target: &Arc<Pager>,
211 root: u32,
212 roots: &mut BTreeMap<u32, u32>,
213 repaired_leaf_backlinks: &mut u64,
214) -> Result<u64> {
215 if root == 0 {
216 return Ok(0);
217 }
218 ensure(!roots.contains_key(&root), "duplicate live tree owner")?;
219 let tree = BTree::with_root(Arc::clone(source), root);
220 let (expected, repairs) = inventory_tree(source, &tree, true)?;
221 *repaired_leaf_backlinks += repairs;
222 let destination = BTree::new(Arc::clone(target));
223 let mut cursor = tree.cursor_first()?;
224 let mut count = 0;
225 let mut previous: Option<Vec<u8>> = None;
226 let mut batch = Vec::new();
227 let mut bytes = 0;
228 while let Some((key, value)) = cursor.next_with_budget(32 * 1024 * 1024)? {
229 ensure(
230 previous.as_ref().is_none_or(|old| old < &key),
231 "source tree keys are not strictly ordered",
232 )?;
233 previous = Some(key.clone());
234 bytes += key.len() + value.len();
235 batch.push((key, value));
236 count += 1;
237 ensure(
238 count <= expected,
239 "tree cursor exceeds structural inventory",
240 )?;
241 if bytes >= 1024 * 1024 || batch.len() >= 512 {
242 destination.bulk_insert_sorted(&batch)?;
243 batch.clear();
244 bytes = 0;
245 }
246 }
247 destination.bulk_insert_sorted(&batch)?;
248 ensure(count == expected, "tree cursor omits owned records")?;
249 roots.insert(root, destination.root_page_id());
250 Ok(count)
251}
252fn inventory_tree(
256 source: &Source,
257 tree: &BTree<Source>,
258 allow_backlinks: bool,
259) -> Result<(u64, u64)> {
260 let pages = tree.retired_pages()?;
261 let mut leaves = Vec::new();
262 let mut expected = 0u64;
263 for id in pages {
264 source.claim(id)?;
265 let page = source.raw_page(id, true)?;
266 if page.page_type()? == PageType::BTreeLeaf {
267 leaves.push((id, number(page.content(), 0)?, number(page.content(), 4)?));
268 expected += u64::from(page.cell_count());
269 }
270 }
271 leaves.reverse();
274 let mut repaired = 0;
275 for (index, &(id, previous, next)) in leaves.iter().enumerate() {
276 ensure(
277 next == leaves.get(index + 1).map_or(0, |leaf| leaf.0),
278 &format!(
279 "broken forward leaf chain at page {id}, root {}",
280 tree.root_page_id()
281 ),
282 )?;
283 if previous != index.checked_sub(1).map_or(0, |i| leaves[i].0) {
284 ensure(
285 allow_backlinks,
286 &format!("broken backward leaf chain at page {id}"),
287 )?;
288 repaired += 1;
289 }
290 }
291 Ok((expected, repaired))
292}
293fn verify_tree(source: &Arc<Source>, destination: &Arc<Source>, old: u32, new: u32) -> Result<()> {
294 let old = BTree::with_root(Arc::clone(source), old);
295 let new = BTree::with_root(Arc::clone(destination), new);
296 inventory_tree(destination, &new, false)?;
297 let mut left = old.cursor_first()?;
298 let mut right = new.cursor_first()?;
299 loop {
300 let expected = left.next_with_budget(32 * 1024 * 1024)?;
301 let actual = right.next_with_budget(32 * 1024 * 1024)?;
302 ensure(
303 expected == actual,
304 "compacted tree failed complete key/value parity",
305 )?;
306 if expected.is_none() {
307 return Ok(());
308 }
309 }
310}
311fn copy_sidecars(source: &Path, destination: &Path) -> Result<()> {
312 for (old, new) in [
315 (
316 f::unified_wal_path(source),
317 f::unified_wal_path(destination),
318 ),
319 (f::engine_wal_path(source), f::engine_wal_path(destination)),
320 (
321 f::logical_wal_path(source),
322 f::logical_wal_path(destination),
323 ),
324 (
325 f::legacy_logical_slots_path(source),
326 f::legacy_logical_slots_path(destination),
327 ),
328 (
329 f::legacy_audit_log_path(source),
330 f::legacy_audit_log_path(destination),
331 ),
332 ] {
333 match File::open(&old) {
334 Ok(mut input) => {
335 let mut output = create_new(&new)?;
336 std::io::copy(&mut input, &mut output)?;
337 output.sync_all()?;
338 verify_file_copy(&old, &new)?;
339 }
340 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
341 Err(error) => return Err(error.into()),
342 }
343 }
344 let support = f::support_dir_for(source);
345 if support.exists() {
346 validate_empty_directory(&support, 0)?;
347 copy_directory(&support, &f::support_dir_for(destination), 0)?;
348 }
349 let old = source.with_file_name(format!("{}.ops", f::data_file_name(source)));
350 let new = destination.with_file_name(format!("{}.ops", f::data_file_name(destination)));
351 if old.exists() {
352 validate_operational(&old)?;
353 copy_directory(&old, &new, 0)?;
354 }
355 Ok(())
356}
357fn copy_directory(source: &Path, destination: &Path, depth: usize) -> Result<()> {
358 ensure(depth < 32, "operational directory nesting exceeds limit")?;
359 ensure(
360 std::fs::symlink_metadata(source)?.is_dir(),
361 "operational directory must not be a symlink",
362 )?;
363 std::fs::create_dir(destination)?;
364 for entry in std::fs::read_dir(source)? {
365 let entry = entry?;
366 let kind = entry.file_type()?;
367 let output = destination.join(entry.file_name());
368 if kind.is_dir() {
369 copy_directory(&entry.path(), &output, depth + 1)?;
370 } else {
371 ensure(
372 kind.is_file(),
373 "operational directory contains a symlink or special file",
374 )?;
375 let mut input = File::open(entry.path())?;
376 let mut target = create_new(&output)?;
377 std::io::copy(&mut input, &mut target)?;
378 target.sync_all()?;
379 verify_file_copy(&entry.path(), &output)?;
380 }
381 }
382 sync_directory(destination)
383}
384fn validate_empty_directory(path: &Path, depth: usize) -> Result<()> {
385 ensure(depth < 32, "support directory nesting exceeds limit")?;
386 ensure(
387 std::fs::symlink_metadata(path)?.is_dir(),
388 "support directory must not be a symlink",
389 )?;
390 for entry in std::fs::read_dir(path)? {
391 let entry = entry?;
392 ensure(
393 entry.file_type()?.is_dir(),
394 "non-empty support layout requires separate compaction qualification",
395 )?;
396 validate_empty_directory(&entry.path(), depth + 1)?;
397 }
398 Ok(())
399}
400fn validate_operational(root: &Path) -> Result<()> {
401 ensure(
402 !root.join("forks").exists(),
403 "store forks require separate compaction qualification",
404 )?;
405 let manifest = root.join("manifest.json");
406 ensure(
407 std::fs::metadata(&manifest)?.len() <= 64 * 1024 * 1024,
408 "operational manifest exceeds budget",
409 )?;
410 let document: crate::serde_json::Value =
411 crate::serde_json::from_str(&std::fs::read_to_string(manifest)?)?;
412 fn check(value: &crate::serde_json::Value, depth: usize) -> Result<()> {
413 use crate::serde_json::Value;
414 ensure(depth < 32, "operational manifest exceeds nesting limit")?;
415 match value {
416 Value::Object(fields) => {
417 for (key, value) in fields {
418 if key == "source" || key == "fork_origin" {
419 ensure(
420 matches!(value, Value::Null),
421 "external operational owners require separate qualification",
422 )?;
423 }
424 if key == "path" {
425 let path = Path::new(value.as_str().ok_or("non-string operational path")?);
426 ensure(
427 !path.is_absolute()
428 && path
429 .components()
430 .all(|part| matches!(part, std::path::Component::Normal(_))),
431 "operational path escapes its directory",
432 )?;
433 }
434 check(value, depth + 1)?;
435 }
436 }
437 Value::Array(values) => {
438 for value in values {
439 check(value, depth + 1)?;
440 }
441 }
442 _ => {}
443 }
444 Ok(())
445 }
446 check(&document, 0)
447}
448fn verify_file_copy(source: &Path, destination: &Path) -> Result<()> {
449 fn hash(path: &Path) -> Result<blake3::Hash> {
450 let mut file = File::open(path)?;
451 let mut hash = blake3::Hasher::new();
452 let mut buffer = [0; 64 * 1024];
453 loop {
454 let count = file.read(&mut buffer)?;
455 if count == 0 {
456 break;
457 }
458 hash.update(&buffer[..count]);
459 }
460 Ok(hash.finalize())
461 }
462 ensure(
463 hash(source)? == hash(destination)?,
464 "sidecar failed complete readback parity",
465 )
466}
467fn allocated_bytes(path: &Path) -> Result<Option<u64>> {
468 #[cfg(unix)]
469 {
470 use std::os::unix::fs::MetadataExt;
471 Ok(Some(
472 std::fs::metadata(path)?
473 .blocks()
474 .checked_mul(512)
475 .ok_or("allocated byte count overflow")?,
476 ))
477 }
478 #[cfg(not(unix))]
479 {
480 let _ = path;
481 Ok(None)
482 }
483}
484fn create_new(path: &Path) -> std::io::Result<File> {
485 let mut options = OpenOptions::new();
486 options.write(true).create_new(true);
487 #[cfg(unix)]
488 {
489 use std::os::unix::fs::OpenOptionsExt;
490 options.mode(0o600);
491 }
492 options.open(path)
493}
494fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
495 let mut file = create_new(path)?;
496 file.write_all(bytes)?;
497 file.sync_all()?;
498 Ok(())
499}
500fn sync_directory(_path: &Path) -> Result<()> {
501 #[cfg(unix)]
502 File::open(_path)?.sync_all()?;
503 Ok(())
504}
505fn ensure(condition: bool, message: &str) -> Result<()> {
506 if condition {
507 Ok(())
508 } else {
509 Err(std::io::Error::new(std::io::ErrorKind::InvalidData, message).into())
510 }
511}
512fn number(bytes: &[u8], offset: usize) -> Result<u32> {
513 Ok(u32::from_le_bytes(
514 bytes
515 .get(offset..offset + 4)
516 .ok_or("truncated native integer")?
517 .try_into()?,
518 ))
519}