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
27pub 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 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 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 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 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
132pub 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
147pub 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 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 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 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
250fn 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}