Skip to main content

powdb_backup/
incremental.rs

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