obj_core/backup.rs
1//! Hot backup primitives (M11 #92).
2//!
3//! [`backup_pager_to_path`] is the obj-core entry point the obj
4//! crate's `Db::backup_to` dispatches through. The function takes a
5//! `&Pager<F>` (the caller has already pinned a [`ReaderSnapshot`])
6//! and writes a self-contained `.obj` file at `dest`. Writers may
7//! continue against the source pager throughout; their post-snapshot
8//! commits do not appear in the destination.
9//!
10//! See `docs/format.md` § Hot backup for the algorithm this module
11//! implements.
12//!
13//! # Power-of-ten posture
14//!
15//! - **Rule 2.** The page-copy loop is bounded by
16//! `source.page_count()`; the WAL-overlay loop is bounded by the
17//! snapshot's frozen-view size (which is itself bounded by
18//! `source.page_count()`).
19//! - **Rule 4.** The driver is short; per-phase helpers
20//! (`copy_main_file`, `overlay_frozen_view`, `patch_destination_header`)
21//! factor the work.
22//! - **Rule 7.** No `unwrap` / `expect` in the production path.
23//! Every syscall is `?`-propagated. On any mid-backup error the
24//! destination file is removed best-effort so a half-written
25//! backup does not linger.
26
27#![forbid(unsafe_code)]
28
29use std::path::Path;
30
31use crate::error::{Error, Result};
32use crate::pager::header::{decode_header, encode_header, FileHeader};
33use crate::pager::page::{Page, PageId, PAGE_SIZE};
34use crate::pager::{Pager, ReaderSnapshot};
35use crate::platform::{remove_file_if_exists, FileBackend, FileHandle, SyncMode};
36
37/// Build a self-contained `.obj` file at `dest` carrying the state
38/// of `source` as of `snapshot.pinned_lsn()`.
39///
40/// `snapshot` MUST have been taken against `source` and not yet
41/// dropped; the pin keeps the source's WAL frames at-or-below the
42/// pinned LSN from being reclaimed while the backup runs.
43///
44/// # Algorithm
45///
46/// 1. Refuse to overwrite an existing `dest` (`create_new`).
47/// 2. Copy main-file pages `0..source.page_count()` byte-for-byte.
48/// 3. Overlay every frame in the snapshot's frozen WAL view onto
49/// the destination at its page-id offset.
50/// 4. If the snapshot's frozen view carries a page-0 header frame,
51/// overlay that on top of the main-file copy of page 0.
52/// 5. Patch the destination header: zero `wal_salt`, recompute the
53/// header CRC32C.
54/// 6. `sync_data(SyncMode::Full)` on the destination.
55///
56/// On any mid-backup error the destination file is removed best-
57/// effort so a half-written backup does not linger.
58///
59/// # Errors
60///
61/// - [`Error::BackupDestinationExists`] if `dest` already exists.
62/// - [`Error::BackupNotSupportedForMemoryPager`] if `source` is an
63/// in-memory pager.
64/// - [`Error::Io`] on any syscall failure during the copy.
65/// - [`Error::InvalidFormat`] / [`Error::Corruption`] propagated
66/// from the source header decode (the source's header bytes are
67/// re-encoded with the WAL-staged values applied).
68pub fn backup_pager_to_path<F: FileBackend>(
69 source: &Pager<F>,
70 snapshot: &ReaderSnapshot<F>,
71 dest: impl AsRef<Path>,
72) -> Result<()> {
73 let dest_path = dest.as_ref().to_path_buf();
74 if source.is_memory_backed() {
75 return Err(Error::BackupNotSupportedForMemoryPager);
76 }
77 if dest_path.exists() {
78 return Err(Error::BackupDestinationExists { path: dest_path });
79 }
80 let result = run_backup(source, snapshot, &dest_path);
81 if result.is_err() {
82 // Best-effort cleanup; ignore the result so the original
83 // error is the one the caller sees.
84 let _ = remove_file_if_exists(&dest_path);
85 }
86 result
87}
88
89fn run_backup<F: FileBackend>(
90 source: &Pager<F>,
91 snapshot: &ReaderSnapshot<F>,
92 dest_path: &Path,
93) -> Result<()> {
94 let dest_handle = FileHandle::create_new(dest_path)?;
95 let page_count = source.page_count();
96 dest_handle.set_len(
97 page_count
98 .checked_mul(PAGE_SIZE as u64)
99 .ok_or(Error::InvalidArgument("backup: file size overflow"))?,
100 )?;
101 // #91: the source main file may be SHORTER than `page_count` — a
102 // committed growing transaction advances `page_count` while its
103 // fresh pages still live only in the WAL view, until the next
104 // checkpoint. Gate the byte-for-byte main-file copy by the source's
105 // PHYSICAL high-water (the real on-disk length), NOT by `page_count`;
106 // reading a slot the file does not yet hold would `UnexpectedEof`.
107 // The pages in `[physical, page_count)` are filled by
108 // `overlay_frozen_view` from the snapshot's frozen WAL view (the
109 // backup's pinned snapshot guarantees those frames are still
110 // resident — checkpoint defers while the pin is live). The
111 // destination is still sized to the full `page_count` above, so the
112 // overlay lands those fresh pages at their correct offsets.
113 let physical_page_count = source.main_physical_page_count()?;
114 copy_main_file(source, &dest_handle, physical_page_count)?;
115 overlay_frozen_view(snapshot, &dest_handle, page_count)?;
116 overlay_frozen_header(snapshot, &dest_handle)?;
117 patch_destination_header(&dest_handle)?;
118 dest_handle.sync_data(SyncMode::Full)?;
119 Ok(())
120}
121
122/// Copy every page in `0..physical_page_count` from the source pager's
123/// main file to `dest`. Reads bypass the WAL overlay — that's the
124/// snapshot's job in [`overlay_frozen_view`].
125///
126/// #91: the bound is the source's PHYSICAL high-water, not its
127/// `page_count`. Pages in `[physical_page_count, page_count)` exist
128/// only in the WAL view and are filled by [`overlay_frozen_view`];
129/// reading them off the (too-short) main file here would
130/// `UnexpectedEof`.
131///
132/// Power-of-ten Rule 2: bounded by `physical_page_count` (itself
133/// bounded by `page_count`); Rule 3: a single page-sized scratch
134/// buffer is reused across the loop.
135fn copy_main_file<F: FileBackend>(
136 source: &Pager<F>,
137 dest: &FileHandle,
138 physical_page_count: u64,
139) -> Result<()> {
140 // Page 0 first — it's the header, copied as-is here and patched
141 // below in `patch_destination_header`.
142 let mut buf = Page::zeroed();
143 let page_size_u64 = PAGE_SIZE as u64;
144 let mut id_raw: u64 = 0;
145 while id_raw < physical_page_count {
146 let off = id_raw
147 .checked_mul(page_size_u64)
148 .ok_or(Error::InvalidArgument("backup: byte-offset overflow"))?;
149 if id_raw == 0 {
150 source.read_main_file_page_zero(buf.as_bytes_mut())?;
151 } else {
152 let pid = PageId::new(id_raw)
153 .ok_or(Error::InvalidArgument("backup: zero page id (impossible)"))?;
154 let page = source.read_main_file_page(pid)?;
155 buf.as_bytes_mut().copy_from_slice(page.as_bytes());
156 }
157 dest.write_all_at(buf.as_bytes(), off)?;
158 id_raw = id_raw
159 .checked_add(1)
160 .ok_or(Error::InvalidArgument("backup: page id overflow"))?;
161 }
162 Ok(())
163}
164
165/// Overlay the snapshot's frozen WAL view onto `dest`. After this
166/// returns, every page-id `<= page_count` whose body the snapshot
167/// would observe via [`ReaderSnapshot::read_page`] carries that
168/// observed body in `dest`.
169fn overlay_frozen_view<F: FileBackend>(
170 snapshot: &ReaderSnapshot<F>,
171 dest: &FileHandle,
172 page_count: u64,
173) -> Result<()> {
174 let page_size_u64 = PAGE_SIZE as u64;
175 for (pid, page) in snapshot.frozen_pages() {
176 if pid.get() >= page_count {
177 // The snapshot pinned a frame for a page id that no
178 // longer exists in the source (post-snapshot truncate
179 // is impossible in M11, but defensive). Skip it.
180 continue;
181 }
182 let off = pid
183 .get()
184 .checked_mul(page_size_u64)
185 .ok_or(Error::InvalidArgument("backup: byte-offset overflow"))?;
186 dest.write_all_at(page.as_bytes(), off)?;
187 }
188 Ok(())
189}
190
191/// Overlay the snapshot's frozen page-0 header (if any) on top of
192/// `dest`. This places the WAL-staged catalog root / freelist head /
193/// page count into the destination's header BEFORE
194/// [`patch_destination_header`] zeros the WAL salt.
195fn overlay_frozen_header<F: FileBackend>(
196 snapshot: &ReaderSnapshot<F>,
197 dest: &FileHandle,
198) -> Result<()> {
199 if let Some(header_page) = snapshot.frozen_header() {
200 dest.write_all_at(header_page.as_bytes(), 0)?;
201 }
202 Ok(())
203}
204
205/// Re-encode the destination's page-0 header with `wal_salt` zeroed
206/// and the header CRC recomputed. After this the destination is
207/// self-consistent: a `Db::open(dest)` will see no WAL salt and
208/// will create a fresh empty WAL on first open.
209fn patch_destination_header(dest: &FileHandle) -> Result<()> {
210 let mut page = Page::zeroed();
211 dest.read_exact_at(page.as_bytes_mut(), 0)?;
212 let mut header: FileHeader = decode_header(&page)?;
213 header.wal_salt = [0u8; 16];
214 encode_header(&header, &mut page);
215 dest.write_all_at(page.as_bytes(), 0)?;
216 Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::pager::checksum::write_page_trailer;
223 use crate::pager::{Config, Pager};
224 use tempfile::TempDir;
225
226 fn pid(n: u64) -> PageId {
227 PageId::new(n).expect("non-zero")
228 }
229
230 /// A page body with a marker byte and a valid CRC32C trailer — the
231 /// shape every real caller (B-tree / catalog) passes to `write_page`.
232 fn stamped(marker: u8) -> Page {
233 let mut p = Page::zeroed();
234 p.as_bytes_mut()[0] = marker;
235 write_page_trailer(&mut p);
236 p
237 }
238
239 /// #91 guardrail 4: a backup taken in the window BETWEEN a growing
240 /// commit and its next checkpoint must round-trip the fresh pages —
241 /// even though the source main file is physically SHORTER than its
242 /// `page_count` (the fresh bodies live only in the WAL view). The
243 /// `copy_main_file` loop is gated by the source's PHYSICAL high-water
244 /// (so it never reads past the short file's EOF) and
245 /// `overlay_frozen_view` fills the WAL-resident fresh pages.
246 #[test]
247 fn backup_between_growing_commit_and_checkpoint_round_trips() {
248 let dir = TempDir::new().expect("tmp");
249 let src = dir.path().join("src.obj");
250 let dst = dir.path().join("backup.obj");
251 // checkpoint_threshold = MAX so the growing commit is NOT
252 // auto-checkpointed: the source stays in the #91 window.
253 let cfg = Config::default().with_checkpoint_threshold(u64::MAX);
254
255 let (a, b) = {
256 let mut p = Pager::open(&src, cfg).expect("open source");
257 p.begin_txn();
258 // Page `a`: alloc + write + commit + CHECKPOINT, so it lands
259 // physically on the main file.
260 let a = p.alloc_page().expect("alloc a");
261 p.write_page(a, &stamped(0xA1)).expect("write a");
262 let _ = p.commit().expect("commit a");
263 p.checkpoint().expect("checkpoint a");
264 // Page `b`: alloc + write + commit, NO checkpoint — `b` is
265 // beyond the physical high-water, resident only in the WAL.
266 let b = p.alloc_page().expect("alloc b");
267 p.write_page(b, &stamped(0xB2)).expect("write b");
268 let _ = p.commit().expect("commit b");
269
270 // Confirm the window: the source main file does NOT physically
271 // cover `b` (this is what makes the guardrail load-bearing).
272 let physical = p.main_physical_page_count().expect("physical");
273 assert!(
274 b.get() >= physical,
275 "test premise: fresh page `b` must be beyond the physical \
276 high-water (b={}, physical={physical})",
277 b.get(),
278 );
279
280 // Pin a snapshot and back up in this exact window. The pin
281 // keeps `b`'s WAL frame from being reclaimed.
282 let snap = p.reader_snapshot().expect("snap");
283 backup_pager_to_path(&p, &snap, &dst).expect("backup");
284 (a.get(), b.get())
285 };
286
287 // Reopen the backup: both pages must read back their bodies. `a`
288 // came from the copied main file; `b` came from the overlaid
289 // frozen WAL view. Neither path may `UnexpectedEof`.
290 let mut bp = Pager::open(&dst, Config::default()).expect("open backup");
291 let ra = bp.read_page(pid(a)).expect("read a from backup");
292 assert_eq!(ra.as_bytes()[0], 0xA1, "checkpointed page survives backup");
293 let rb = bp.read_page(pid(b)).expect("read b from backup");
294 assert_eq!(
295 rb.as_bytes()[0],
296 0xB2,
297 "WAL-resident fresh page survives backup via overlay_frozen_view",
298 );
299 }
300}