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 copy_main_file(source, &dest_handle, page_count)?;
102 overlay_frozen_view(snapshot, &dest_handle, page_count)?;
103 overlay_frozen_header(snapshot, &dest_handle)?;
104 patch_destination_header(&dest_handle)?;
105 dest_handle.sync_data(SyncMode::Full)?;
106 Ok(())
107}
108
109/// Copy every page in `0..page_count` from the source pager's main
110/// file to `dest`. Reads bypass the WAL overlay — that's the
111/// snapshot's job in [`overlay_frozen_view`].
112///
113/// Power-of-ten Rule 2: bounded by `page_count`; Rule 3: a single
114/// page-sized scratch buffer is reused across the loop.
115fn copy_main_file<F: FileBackend>(
116 source: &Pager<F>,
117 dest: &FileHandle,
118 page_count: u64,
119) -> Result<()> {
120 // Page 0 first — it's the header, copied as-is here and patched
121 // below in `patch_destination_header`.
122 let mut buf = Page::zeroed();
123 let page_size_u64 = PAGE_SIZE as u64;
124 let mut id_raw: u64 = 0;
125 while id_raw < page_count {
126 let off = id_raw
127 .checked_mul(page_size_u64)
128 .ok_or(Error::InvalidArgument("backup: byte-offset overflow"))?;
129 if id_raw == 0 {
130 source.read_main_file_page_zero(buf.as_bytes_mut())?;
131 } else {
132 let pid = PageId::new(id_raw)
133 .ok_or(Error::InvalidArgument("backup: zero page id (impossible)"))?;
134 let page = source.read_main_file_page(pid)?;
135 buf.as_bytes_mut().copy_from_slice(page.as_bytes());
136 }
137 dest.write_all_at(buf.as_bytes(), off)?;
138 id_raw = id_raw
139 .checked_add(1)
140 .ok_or(Error::InvalidArgument("backup: page id overflow"))?;
141 }
142 Ok(())
143}
144
145/// Overlay the snapshot's frozen WAL view onto `dest`. After this
146/// returns, every page-id `<= page_count` whose body the snapshot
147/// would observe via [`ReaderSnapshot::read_page`] carries that
148/// observed body in `dest`.
149fn overlay_frozen_view<F: FileBackend>(
150 snapshot: &ReaderSnapshot<F>,
151 dest: &FileHandle,
152 page_count: u64,
153) -> Result<()> {
154 let page_size_u64 = PAGE_SIZE as u64;
155 for (pid, page) in snapshot.frozen_pages() {
156 if pid.get() >= page_count {
157 // The snapshot pinned a frame for a page id that no
158 // longer exists in the source (post-snapshot truncate
159 // is impossible in M11, but defensive). Skip it.
160 continue;
161 }
162 let off = pid
163 .get()
164 .checked_mul(page_size_u64)
165 .ok_or(Error::InvalidArgument("backup: byte-offset overflow"))?;
166 dest.write_all_at(page.as_bytes(), off)?;
167 }
168 Ok(())
169}
170
171/// Overlay the snapshot's frozen page-0 header (if any) on top of
172/// `dest`. This places the WAL-staged catalog root / freelist head /
173/// page count into the destination's header BEFORE
174/// [`patch_destination_header`] zeros the WAL salt.
175fn overlay_frozen_header<F: FileBackend>(
176 snapshot: &ReaderSnapshot<F>,
177 dest: &FileHandle,
178) -> Result<()> {
179 if let Some(header_page) = snapshot.frozen_header() {
180 dest.write_all_at(header_page.as_bytes(), 0)?;
181 }
182 Ok(())
183}
184
185/// Re-encode the destination's page-0 header with `wal_salt` zeroed
186/// and the header CRC recomputed. After this the destination is
187/// self-consistent: a `Db::open(dest)` will see no WAL salt and
188/// will create a fresh empty WAL on first open.
189fn patch_destination_header(dest: &FileHandle) -> Result<()> {
190 let mut page = Page::zeroed();
191 dest.read_exact_at(page.as_bytes_mut(), 0)?;
192 let mut header: FileHeader = decode_header(&page)?;
193 header.wal_salt = [0u8; 16];
194 encode_header(&header, &mut page);
195 dest.write_all_at(page.as_bytes(), 0)?;
196 Ok(())
197}