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
33pub 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 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 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 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 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
130pub 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
145pub 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 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 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 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
240fn 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}