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