Skip to main content

reddb_server/storage/
compact.rs

1//! Offline native rewrite. Source is read-locked against writers and never
2//! opened by a recovering writer. A new directory owns all output; the main
3//! filename is published only after sync and a complete record-by-record
4//! readback.
5use 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    /// Includes exact persisted key and secondary-index records.
34    pub records: u64,
35    pub trees: usize,
36    /// Historical stale backlinks reconstructed from validated forward/owner order.
37    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
57/// Rewrite a closed, checkpointed paged file into an exclusively-created output
58/// directory. On failure the source is unchanged and the incomplete output is
59/// retained for diagnosis. This does not switch a serving database or vacuum MVCC.
60pub 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    // Exclusive creation prevents clobbering files, symlinks and other runs.
73    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    // Preserve source schema/LSN, but retain the newly allocated page boundary.
141    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    // hard_link is create-new even if an unexpected file appears during work.
195    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}
252// Interior ownership and forward traversal must agree exactly. Old split code
253// omitted the successor backlink update; that redundant edge can be rebuilt
254// without guessing any ownership or key/value. Never repair forward omissions.
255fn 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    // Ownership traversal is right-to-left. A cursor follows the independent
272    // leaf chain; prove it visits exactly the structurally owned leaves.
273    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    // These files contain logical records, not physical page addresses. Empty
313    // checkpointed WALs also preserve their version exactly.
314    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}