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    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}