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