Skip to main content

powdb_backup/
incremental.rs

1use crate::manifest::{
2    active_durable_file_names, current_sync_snapshot_metadata, validate_catalog_transition,
3    BackupManifest, ChangedFile, IncrementManifest,
4};
5use crate::restore::{
6    apply_restore_sync_mode, ensure_empty_dir, validate_backup_file_name, validate_delta_file_name,
7    verify_and_copy_full, RestoreSyncMode,
8};
9use powdb_storage::catalog::{Catalog, CATALOG_LSN_FILE};
10use powdb_storage::page::{page_lsn, PAGE_SIZE};
11use std::io;
12use std::io::{Seek, SeekFrom, Write};
13use std::path::Path;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16fn now_secs() -> u64 {
17    SystemTime::now()
18        .duration_since(UNIX_EPOCH)
19        .map(|d| d.as_secs())
20        .unwrap_or(0)
21}
22
23fn is_paged(name: &str) -> bool {
24    name.ends_with(".heap")
25}
26
27/// Take an incremental backup: copy only heap pages whose page LSN is greater
28/// than `base.source_lsn`, plus every changed non-heap file. B+tree `.idx`
29/// and `.eidx` files are opaque whole-file artifacts and are never interpreted
30/// as heap pages. Builds on `base` (a full backup or a prior increment's
31/// effective state).
32pub fn incremental_backup(
33    catalog: &mut Catalog,
34    base: &BackupManifest,
35    dest: &Path,
36) -> io::Result<IncrementManifest> {
37    base.validate_version()?;
38    powdb_sync::checkpoint_preserving_retained_segments_if_enabled(catalog)?;
39    let source_lsn = catalog.max_lsn();
40    let catalog_version = catalog.active_catalog_version();
41    validate_catalog_transition(base.catalog_version, catalog_version)?;
42    let src = catalog.data_dir().to_path_buf();
43    let sync = current_sync_snapshot_metadata(&src, source_lsn, catalog_version)?;
44    if !sync_metadata_builds_on_same_history(base.sync.as_ref(), sync.as_ref()) {
45        return Err(io::Error::other(
46            "sync identity changed between base and incremental backup",
47        ));
48    }
49    std::fs::create_dir_all(dest)?;
50
51    let mut changed: Vec<ChangedFile> = Vec::new();
52
53    // Stable iteration order so manifests are deterministic.
54    let entries = active_durable_file_names(catalog);
55
56    for name in entries {
57        let path = src.join(&name);
58        if !path.exists() {
59            if name == CATALOG_LSN_FILE {
60                continue;
61            }
62            return Err(io::Error::new(
63                io::ErrorKind::NotFound,
64                format!("catalog references missing durable file {name}"),
65            ));
66        }
67        let bytes = std::fs::read(&path)?;
68
69        if !is_paged(&name) || bytes.len() % PAGE_SIZE != 0 {
70            // Whole-file path: catalog metadata, every B+tree, or a
71            // defensively handled non-page-aligned heap file.
72            let hash = blake3::hash(&bytes).to_hex().to_string();
73            let unchanged = base
74                .files
75                .iter()
76                .any(|f| f.name == name && f.blake3_hex == hash);
77            if unchanged {
78                continue;
79            }
80            std::fs::write(dest.join(&name), &bytes)?;
81            changed.push(ChangedFile::Whole {
82                name,
83                len: bytes.len() as u64,
84                blake3_hex: hash,
85            });
86            continue;
87        }
88
89        // Paged file: diff by page LSN.
90        let total_pages = (bytes.len() / PAGE_SIZE) as u32;
91        let mut page_indices: Vec<u32> = Vec::new();
92        let mut delta: Vec<u8> = Vec::new();
93        for i in 0..total_pages {
94            let start = i as usize * PAGE_SIZE;
95            let chunk = &bytes[start..start + PAGE_SIZE];
96            if page_lsn(chunk) > base.source_lsn {
97                page_indices.push(i);
98                delta.extend_from_slice(&i.to_le_bytes());
99                delta.extend_from_slice(chunk);
100            }
101        }
102        if page_indices.is_empty() {
103            // Nothing changed for this file; omit entirely.
104            continue;
105        }
106        let delta_file = format!("{name}.delta");
107        std::fs::write(dest.join(&delta_file), &delta)?;
108        let delta_blake3_hex = blake3::hash(&delta).to_hex().to_string();
109        changed.push(ChangedFile::Pages {
110            name,
111            total_pages,
112            page_indices,
113            delta_file,
114            delta_len: delta.len() as u64,
115            delta_blake3_hex,
116        });
117    }
118
119    let manifest = IncrementManifest {
120        format_version: IncrementManifest::FORMAT_VERSION,
121        created_unix_secs: now_secs(),
122        base_source_lsn: base.source_lsn,
123        source_lsn,
124        catalog_version,
125        sync,
126        changed,
127    };
128    manifest.write(dest)?;
129    Ok(manifest)
130}
131
132/// Rebuild a data dir from a full base backup plus an ordered chain of
133/// increments. Verifies chain continuity (each increment's `base_source_lsn`
134/// must equal the running high-water LSN) and blake3-checks every file/delta,
135/// then validates the result by reopening the catalog.
136///
137/// Coarse PITR = choosing which prefix of the chain to pass here.
138pub fn restore_chain(full_dir: &Path, increment_dirs: &[&Path], dest: &Path) -> io::Result<()> {
139    restore_chain_with_sync_mode(
140        full_dir,
141        increment_dirs,
142        dest,
143        RestoreSyncMode::StripSyncIdentity,
144    )
145}
146
147/// Rebuild a data dir from a full backup plus an ordered chain of increments
148/// with explicit sync identity semantics.
149pub fn restore_chain_with_sync_mode(
150    full_dir: &Path,
151    increment_dirs: &[&Path],
152    dest: &Path,
153    sync_mode: RestoreSyncMode,
154) -> io::Result<()> {
155    ensure_empty_dir(dest)?;
156
157    // 1. Lay down the full base.
158    let full_manifest = BackupManifest::read(full_dir)?;
159    verify_and_copy_full(&full_manifest, full_dir, dest)?;
160    let mut running_lsn = full_manifest.source_lsn;
161    let mut running_catalog_version = full_manifest.catalog_version;
162    let mut running_sync = full_manifest.sync.clone();
163
164    // 2. Apply each increment in order.
165    for inc_dir in increment_dirs {
166        let inc = IncrementManifest::read(inc_dir)?;
167        if inc.base_source_lsn != running_lsn {
168            return Err(io::Error::other(format!(
169                "increment chain broken: expected base lsn {}, increment built on {}",
170                running_lsn, inc.base_source_lsn
171            )));
172        }
173        if !sync_metadata_builds_on_same_history(full_manifest.sync.as_ref(), inc.sync.as_ref()) {
174            return Err(io::Error::other(
175                "increment sync identity does not match full backup identity",
176            ));
177        }
178        validate_catalog_transition(running_catalog_version, inc.catalog_version)?;
179        for cf in &inc.changed {
180            match cf {
181                ChangedFile::Whole {
182                    name,
183                    len: _,
184                    blake3_hex,
185                } => {
186                    validate_backup_file_name(name)?;
187                    let bytes = std::fs::read(inc_dir.join(name))?;
188                    let hash = blake3::hash(&bytes).to_hex().to_string();
189                    if &hash != blake3_hex {
190                        return Err(io::Error::other(format!(
191                            "integrity check failed for {name}: blake3 mismatch (increment is corrupt)"
192                        )));
193                    }
194                    std::fs::write(dest.join(name), &bytes)?;
195                }
196                ChangedFile::Pages {
197                    name,
198                    total_pages,
199                    page_indices,
200                    delta_file,
201                    delta_len: _,
202                    delta_blake3_hex,
203                } => {
204                    validate_delta_file_name(delta_file, name)?;
205                    let delta = std::fs::read(inc_dir.join(delta_file))?;
206                    let hash = blake3::hash(&delta).to_hex().to_string();
207                    if &hash != delta_blake3_hex {
208                        return Err(io::Error::other(format!(
209                            "integrity check failed for {delta_file}: blake3 mismatch (increment is corrupt)"
210                        )));
211                    }
212                    apply_page_delta(&dest.join(name), *total_pages, page_indices, &delta)?;
213                }
214            }
215        }
216        running_lsn = inc.source_lsn;
217        running_catalog_version = inc.catalog_version;
218        running_sync = inc.sync.clone();
219    }
220
221    apply_restore_sync_mode(running_sync.as_ref(), dest, sync_mode)?;
222
223    // 3. Validate the reconstructed DB opens (LSN invariant).
224    let cat = Catalog::open(dest)?;
225    if cat.active_catalog_version() != running_catalog_version {
226        return Err(io::Error::new(
227            io::ErrorKind::InvalidData,
228            "restored catalog format does not match increment manifest",
229        ));
230    }
231    drop(cat);
232    Ok(())
233}
234
235fn sync_metadata_builds_on_same_history(
236    base: Option<&crate::manifest::SyncSnapshotMetadata>,
237    next: Option<&crate::manifest::SyncSnapshotMetadata>,
238) -> bool {
239    match (base, next) {
240        (None, None) => true,
241        (Some(base), Some(next)) => {
242            base.identity == next.identity
243                && base.wal_format_version == next.wal_format_version
244                && base.retained_segment_format_version == next.retained_segment_format_version
245        }
246        _ => false,
247    }
248}
249
250/// Open/extend `path` to `total_pages * PAGE_SIZE` bytes, then write each
251/// page record from the delta at its target offset. Pages not in the delta are
252/// left untouched (already correct from the base / prior increments).
253fn apply_page_delta(
254    path: &Path,
255    total_pages: u32,
256    page_indices: &[u32],
257    delta: &[u8],
258) -> io::Result<()> {
259    let record_len = 4 + PAGE_SIZE;
260    let expected = page_indices
261        .len()
262        .checked_mul(record_len)
263        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "delta length overflow"))?;
264    if delta.len() != expected {
265        return Err(io::Error::new(
266            io::ErrorKind::InvalidData,
267            format!(
268                "delta for {} has length {} but {} page records expected {}",
269                path.display(),
270                delta.len(),
271                page_indices.len(),
272                expected
273            ),
274        ));
275    }
276
277    let mut file = std::fs::OpenOptions::new()
278        .read(true)
279        .write(true)
280        .create(true)
281        .truncate(false)
282        .open(path)?;
283    let target_len = total_pages as u64 * PAGE_SIZE as u64;
284    if file.metadata()?.len() < target_len {
285        file.set_len(target_len)?;
286    }
287
288    let mut off = 0usize;
289    for expected_idx in page_indices {
290        if *expected_idx >= total_pages {
291            return Err(io::Error::new(
292                io::ErrorKind::InvalidData,
293                format!(
294                    "increment manifest page index {expected_idx} is outside {total_pages} pages for {}",
295                    path.display()
296                ),
297            ));
298        }
299        let idx = u32::from_le_bytes([delta[off], delta[off + 1], delta[off + 2], delta[off + 3]]);
300        if idx != *expected_idx {
301            return Err(io::Error::new(
302                io::ErrorKind::InvalidData,
303                format!(
304                    "delta page index {idx} does not match manifest page index {expected_idx} for {}",
305                    path.display()
306                ),
307            ));
308        }
309        let page = &delta[off + 4..off + 4 + PAGE_SIZE];
310        file.seek(SeekFrom::Start(idx as u64 * PAGE_SIZE as u64))?;
311        file.write_all(page)?;
312        off += record_len;
313    }
314    file.flush()?;
315    Ok(())
316}