Skip to main content

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}