powdb_backup/
incremental.rs1use 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
27pub 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 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 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 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 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
116pub fn restore_chain(full_dir: &Path, increment_dirs: &[&Path], dest: &Path) -> io::Result<()> {
123 ensure_empty_dir(dest)?;
124
125 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 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 let cat = Catalog::open(dest)?;
181 drop(cat);
182 Ok(())
183}
184
185fn 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}