Skip to main content

reddb_server/storage/
compact.rs

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