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 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    /// Includes exact persisted key and secondary-index records.
32    pub records: u64,
33    pub trees: usize,
34    /// Historical stale backlinks reconstructed from validated forward/owner order.
35    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
55/// Rewrite a closed, checkpointed paged file into an exclusively-created output
56/// directory. On failure the source is unchanged and the incomplete output is
57/// retained for diagnosis. This does not switch a serving database or vacuum MVCC.
58pub 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    // Exclusive creation prevents clobbering files, symlinks and other runs.
71    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    // Preserve source schema/LSN, but retain the newly allocated page boundary.
139    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    // hard_link is create-new even if an unexpected file appears during work.
193    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}
250// Interior ownership and forward traversal must agree exactly. Old split code
251// omitted the successor backlink update; that redundant edge can be rebuilt
252// without guessing any ownership or key/value. Never repair forward omissions.
253fn 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    // Ownership traversal is right-to-left. A cursor follows the independent
270    // leaf chain; prove it visits exactly the structurally owned leaves.
271    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    // These files contain logical records, not physical page addresses. Empty
311    // checkpointed WALs also preserve their version exactly.
312    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}