Skip to main content

powdb_backup/
incremental.rs

1use crate::manifest::{BackupManifest, ChangedFile, IncrementManifest};
2use crate::restore::{
3    ensure_empty_dir, validate_backup_file_name, validate_delta_file_name, verify_and_copy_full,
4};
5use powdb_storage::catalog::Catalog;
6use powdb_storage::page::{page_lsn, PAGE_SIZE};
7use std::io;
8use std::io::{Seek, SeekFrom, Write};
9use std::path::Path;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12fn now_secs() -> u64 {
13    SystemTime::now()
14        .duration_since(UNIX_EPOCH)
15        .map(|d| d.as_secs())
16        .unwrap_or(0)
17}
18
19fn is_durable(name: &str) -> bool {
20    name == "catalog.bin" || name.ends_with(".heap") || name.ends_with(".idx")
21}
22
23fn is_paged(name: &str) -> bool {
24    name.ends_with(".heap") || name.ends_with(".idx")
25}
26
27/// Take an incremental backup: copy only the pages of each heap/idx file whose
28/// page LSN is greater than `base.source_lsn`, plus any non-paged file (e.g.
29/// catalog.bin) that changed. Builds on `base` (a full backup or a prior
30/// increment's effective state).
31pub fn incremental_backup(
32    catalog: &mut Catalog,
33    base: &BackupManifest,
34    dest: &Path,
35) -> io::Result<IncrementManifest> {
36    catalog.checkpoint()?;
37    let source_lsn = catalog.max_lsn();
38    let src = catalog.data_dir().to_path_buf();
39    std::fs::create_dir_all(dest)?;
40
41    let mut changed: Vec<ChangedFile> = Vec::new();
42
43    // Stable iteration order so manifests are deterministic.
44    let mut entries: Vec<_> = std::fs::read_dir(&src)?
45        .filter_map(|e| e.ok())
46        .map(|e| e.file_name().to_string_lossy().to_string())
47        .filter(|n| is_durable(n))
48        .collect();
49    entries.sort();
50
51    for name in entries {
52        let path = src.join(&name);
53        let bytes = std::fs::read(&path)?;
54
55        if !is_paged(&name) || bytes.len() % PAGE_SIZE != 0 {
56            // Whole-file path: catalog.bin, or a defensively-handled
57            // non-page-aligned paged file. Only record if changed vs base.
58            let hash = blake3::hash(&bytes).to_hex().to_string();
59            let unchanged = base
60                .files
61                .iter()
62                .any(|f| f.name == name && f.blake3_hex == hash);
63            if unchanged {
64                continue;
65            }
66            std::fs::write(dest.join(&name), &bytes)?;
67            changed.push(ChangedFile::Whole {
68                name,
69                len: bytes.len() as u64,
70                blake3_hex: hash,
71            });
72            continue;
73        }
74
75        // Paged file: diff by page LSN.
76        let total_pages = (bytes.len() / PAGE_SIZE) as u32;
77        let mut page_indices: Vec<u32> = Vec::new();
78        let mut delta: Vec<u8> = Vec::new();
79        for i in 0..total_pages {
80            let start = i as usize * PAGE_SIZE;
81            let chunk = &bytes[start..start + PAGE_SIZE];
82            if page_lsn(chunk) > base.source_lsn {
83                page_indices.push(i);
84                delta.extend_from_slice(&i.to_le_bytes());
85                delta.extend_from_slice(chunk);
86            }
87        }
88        if page_indices.is_empty() {
89            // Nothing changed for this file; omit entirely.
90            continue;
91        }
92        let delta_file = format!("{name}.delta");
93        std::fs::write(dest.join(&delta_file), &delta)?;
94        let delta_blake3_hex = blake3::hash(&delta).to_hex().to_string();
95        changed.push(ChangedFile::Pages {
96            name,
97            total_pages,
98            page_indices,
99            delta_file,
100            delta_len: delta.len() as u64,
101            delta_blake3_hex,
102        });
103    }
104
105    let manifest = IncrementManifest {
106        format_version: IncrementManifest::FORMAT_VERSION,
107        created_unix_secs: now_secs(),
108        base_source_lsn: base.source_lsn,
109        source_lsn,
110        changed,
111    };
112    manifest.write(dest)?;
113    Ok(manifest)
114}
115
116/// Rebuild a data dir from a full base backup plus an ordered chain of
117/// increments. Verifies chain continuity (each increment's `base_source_lsn`
118/// must equal the running high-water LSN) and blake3-checks every file/delta,
119/// then validates the result by reopening the catalog.
120///
121/// Coarse PITR = choosing which prefix of the chain to pass here.
122pub fn restore_chain(full_dir: &Path, increment_dirs: &[&Path], dest: &Path) -> io::Result<()> {
123    ensure_empty_dir(dest)?;
124
125    // 1. Lay down the full base.
126    let full_manifest = BackupManifest::read(full_dir)?;
127    verify_and_copy_full(&full_manifest, full_dir, dest)?;
128    let mut running_lsn = full_manifest.source_lsn;
129
130    // 2. Apply each increment in order.
131    for inc_dir in increment_dirs {
132        let inc = IncrementManifest::read(inc_dir)?;
133        if inc.base_source_lsn != running_lsn {
134            return Err(io::Error::other(format!(
135                "increment chain broken: expected base lsn {}, increment built on {}",
136                running_lsn, inc.base_source_lsn
137            )));
138        }
139        for cf in &inc.changed {
140            match cf {
141                ChangedFile::Whole {
142                    name,
143                    len: _,
144                    blake3_hex,
145                } => {
146                    validate_backup_file_name(name)?;
147                    let bytes = std::fs::read(inc_dir.join(name))?;
148                    let hash = blake3::hash(&bytes).to_hex().to_string();
149                    if &hash != blake3_hex {
150                        return Err(io::Error::other(format!(
151                            "integrity check failed for {name}: blake3 mismatch (increment is corrupt)"
152                        )));
153                    }
154                    std::fs::write(dest.join(name), &bytes)?;
155                }
156                ChangedFile::Pages {
157                    name,
158                    total_pages,
159                    page_indices,
160                    delta_file,
161                    delta_len: _,
162                    delta_blake3_hex,
163                } => {
164                    validate_delta_file_name(delta_file, name)?;
165                    let delta = std::fs::read(inc_dir.join(delta_file))?;
166                    let hash = blake3::hash(&delta).to_hex().to_string();
167                    if &hash != delta_blake3_hex {
168                        return Err(io::Error::other(format!(
169                            "integrity check failed for {delta_file}: blake3 mismatch (increment is corrupt)"
170                        )));
171                    }
172                    apply_page_delta(&dest.join(name), *total_pages, page_indices, &delta)?;
173                }
174            }
175        }
176        running_lsn = inc.source_lsn;
177    }
178
179    // 3. Validate the reconstructed DB opens (LSN invariant).
180    let cat = Catalog::open(dest)?;
181    drop(cat);
182    Ok(())
183}
184
185/// Open/extend `path` to `total_pages * PAGE_SIZE` bytes, then write each
186/// page record from the delta at its target offset. Pages not in the delta are
187/// left untouched (already correct from the base / prior increments).
188fn apply_page_delta(
189    path: &Path,
190    total_pages: u32,
191    page_indices: &[u32],
192    delta: &[u8],
193) -> io::Result<()> {
194    let record_len = 4 + PAGE_SIZE;
195    let expected = page_indices.len() * record_len;
196    if delta.len() != expected {
197        return Err(io::Error::other(format!(
198            "delta for {} has length {} but {} page records expected {}",
199            path.display(),
200            delta.len(),
201            page_indices.len(),
202            expected
203        )));
204    }
205
206    let mut file = std::fs::OpenOptions::new()
207        .read(true)
208        .write(true)
209        .create(true)
210        .truncate(false)
211        .open(path)?;
212    let target_len = total_pages as u64 * PAGE_SIZE as u64;
213    if file.metadata()?.len() < target_len {
214        file.set_len(target_len)?;
215    }
216
217    let mut off = 0usize;
218    while off < delta.len() {
219        let idx = u32::from_le_bytes([delta[off], delta[off + 1], delta[off + 2], delta[off + 3]]);
220        let page = &delta[off + 4..off + 4 + PAGE_SIZE];
221        file.seek(SeekFrom::Start(idx as u64 * PAGE_SIZE as u64))?;
222        file.write_all(page)?;
223        off += record_len;
224    }
225    file.flush()?;
226    Ok(())
227}