Skip to main content

core_api/
restore.rs

1//! Seeding a store directory from a backup.
2//!
3//! The mirror of [`GraphDb::backup_to`](crate::GraphDb::backup_to), and it
4//! lives beside it for the same reason: a restore is a file-level copy of a
5//! store directory followed by an open that proves the copy is a store.
6//!
7//! This was the CLI's `--restore-from` implementation until v0.6.10, when the
8//! Python binding needed it too. `cli::restore_if_empty` is now a thin wrapper
9//! over [`restore_if_empty`] here, with the CLI's error type; the semantics are
10//! unchanged.
11
12use crate::{GraphDb, GraphError, Result};
13use std::path::{Path, PathBuf};
14
15/// What [`restore_if_empty`] did.
16#[derive(Debug, PartialEq, Eq)]
17pub enum RestoreOutcome {
18    /// `db_dir` already holds a store; nothing was copied.
19    AlreadyPresent,
20    /// Seeded from `from`.
21    Restored {
22        from: PathBuf,
23        files: Vec<String>,
24        bytes: u64,
25    },
26    /// `from` held no backup: nothing under it looks like a store.
27    Empty,
28}
29
30/// Every file [`GraphDb::backup_to`](crate::GraphDb::backup_to) copies that is
31/// not a WAL archive.
32///
33/// Kept in the same order, so a restore writes them the way a backup wrote
34/// them. Archives are found by name at copy time, since their count varies.
35const RESTORE_FILES: [&str; 8] = [
36    "snapshot.bin",
37    "snapshot.bin.bak",
38    "wal.bin",
39    "wal.floor",
40    "wal.genesis",
41    "roles.json",
42    "commit_times.bin",
43    "snapshot.intent",
44];
45
46/// Is there a store in `dir` already?
47///
48/// A store is "present" when `dir` holds `snapshot.bin` or a non-empty
49/// `wal.bin`. An empty `wal.bin` is what a crashed first boot leaves behind,
50/// and seeding over it is the whole point of `--restore-from`.
51pub fn holds_a_store(dir: &Path) -> bool {
52    if dir.join("snapshot.bin").is_file() {
53        return true;
54    }
55    std::fs::metadata(dir.join("wal.bin"))
56        .map(|m| m.is_file() && m.len() > 0)
57        .unwrap_or(false)
58}
59
60/// How recently a backup directory was written: the newest mtime among the
61/// files that make it a store.
62///
63/// `snapshot.bin` alone is not enough to rank by, because a store that has
64/// never snapshotted backs up as `wal.bin` and nothing else — which is exactly
65/// what `mushroomdb demo` then `mushroomdb backup` produces.
66fn backup_mtime(dir: &Path) -> Option<std::time::SystemTime> {
67    ["snapshot.bin", "wal.bin"]
68        .iter()
69        .filter_map(|n| {
70            std::fs::metadata(dir.join(n))
71                .and_then(|m| m.modified())
72                .ok()
73        })
74        .max()
75}
76
77/// Pick the backup under `from`, if there is one.
78///
79/// `from` is either a backup directory itself (it holds a store) or a
80/// directory of them, in which case the immediate subdirectory named `latest`
81/// wins outright if it holds one — so a symlink or a rolling copy can name
82/// itself — and otherwise the newest by mtime wins.
83///
84/// "Holds a store" is [`holds_a_store`], the same predicate that decides
85/// whether `db_dir` needs seeding. A backup of a never-snapshotted store is
86/// `wal.bin` and nothing else, and it carries every commit; ranking on
87/// `snapshot.bin` alone would skip it and start empty.
88fn choose_backup(from: &Path) -> Option<PathBuf> {
89    if holds_a_store(from) {
90        return Some(from.to_path_buf());
91    }
92    let entries = std::fs::read_dir(from).ok()?;
93    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
94    for entry in entries.flatten() {
95        let dir = entry.path();
96        if !holds_a_store(&dir) {
97            continue;
98        }
99        if dir.file_name().map(|n| n == "latest").unwrap_or(false) {
100            return Some(dir);
101        }
102        let Some(mtime) = backup_mtime(&dir) else {
103            continue;
104        };
105        // Ties break on the path, so a vault of same-second backups still
106        // picks the same one on every boot.
107        let better = match &best {
108            None => true,
109            Some((best_mtime, best_dir)) => (mtime, &dir) > (*best_mtime, best_dir),
110        };
111        if better {
112            best = Some((mtime, dir));
113        }
114    }
115    best.map(|(_, dir)| dir)
116}
117
118/// Seed `db_dir` from the newest backup under `from`, if `db_dir` has no store.
119///
120/// A store is "present" when `db_dir` holds `snapshot.bin` or a non-empty
121/// `wal.bin`. `from` is either a backup directory itself (it holds a store by
122/// that same test) or a directory of them, in which case the immediate
123/// subdirectory named `latest` wins if it holds one, else the newest by mtime.
124///
125/// The restore is all-or-nothing. The backup is copied into a staging
126/// directory **inside** `db_dir` and opened there — the same CRC and replay
127/// checks any open runs — and only a copy that opened is moved into place. Any
128/// *error* removes the staging directory (or, once files have started moving,
129/// undoes the moves already made) and leaves `db_dir` exactly as it was, so
130/// the error names the paths, the operator can fix the backup, and the next
131/// boot restores rather than reporting [`RestoreOutcome::AlreadyPresent`] over
132/// a half-written store. That unwind is process-local: a crash between the
133/// two renames that install the staged files (not a returned error, but the
134/// process dying) can leave `db_dir` holding one file but not the other. The
135/// next boot sees that partial store as already present and reports
136/// [`RestoreOutcome::AlreadyPresent`] rather than restoring over it — clear
137/// the directory and restore again.
138///
139/// Staging lives inside `db_dir` on purpose: `db_dir` is typically the mount
140/// point, so a sibling directory could land on another filesystem and turn the
141/// final moves into cross-device copies.
142///
143/// # Errors
144///
145/// Whatever creating `db_dir`, copying a file, opening the staged copy, or
146/// moving it into place returned, as [`GraphError::Io`] carrying the message
147/// verbatim — the paths are in the message, and a caller that must tell the
148/// causes apart has them in the text rather than in the variant.
149pub fn restore_if_empty(db_dir: &Path, from: &Path) -> Result<RestoreOutcome> {
150    if holds_a_store(db_dir) {
151        return Ok(RestoreOutcome::AlreadyPresent);
152    }
153    let Some(backup) = choose_backup(from) else {
154        return Ok(RestoreOutcome::Empty);
155    };
156
157    std::fs::create_dir_all(db_dir)
158        .map_err(|e| failed(format!("restore into {}: {e}", db_dir.display())))?;
159
160    // Named for this process, so two `serve` processes racing onto one fresh
161    // volume stage into separate directories rather than over each other.
162    let staging = db_dir.join(format!(".restore-{}", std::process::id()));
163    let _ = std::fs::remove_dir_all(&staging); // a previous run that was killed
164    std::fs::create_dir_all(&staging)
165        .map_err(|e| failed(format!("restore into {}: {e}", staging.display())))?;
166
167    let outcome = stage_and_install(db_dir, &staging, &backup);
168    // Whether it worked or not: the staging directory never outlives the call.
169    // On success it holds only what opening the copy created (`LOCK`), since
170    // the restored files were moved out of it.
171    let _ = std::fs::remove_dir_all(&staging);
172    outcome
173}
174
175/// A restore failure, carrying `msg` and nothing else.
176///
177/// `std::io::Error::other` displays as its message alone, so the text a caller
178/// sees is the text built here — which is what lets the CLI keep printing the
179/// messages it always has.
180fn failed(msg: String) -> GraphError {
181    GraphError::Io(std::io::Error::other(msg))
182}
183
184/// Copy `backup` into `staging`, prove it opens, then move it into `db_dir`.
185///
186/// Split out of [`restore_if_empty`] so every early return runs through one
187/// cleanup of `staging` at the call site.
188fn stage_and_install(db_dir: &Path, staging: &Path, backup: &Path) -> Result<RestoreOutcome> {
189    let mut names: Vec<String> = RESTORE_FILES.iter().map(|n| n.to_string()).collect();
190    let mut archives: Vec<String> = std::fs::read_dir(backup)
191        .map_err(|e| failed(format!("restore from {}: {e}", backup.display())))?
192        .flatten()
193        .filter_map(|e| e.file_name().into_string().ok())
194        .filter(|n| n.starts_with("wal.") && n.ends_with(".archive"))
195        .collect();
196    archives.sort();
197    names.extend(archives);
198
199    let mut files = Vec::new();
200    let mut bytes = 0u64;
201    for name in names {
202        let src = backup.join(&name);
203        if !src.is_file() {
204            continue;
205        }
206        let n = std::fs::copy(&src, staging.join(&name))
207            .map_err(|e| failed(format!("restore {} from {}: {e}", name, backup.display())))?;
208        bytes += n;
209        files.push(name);
210    }
211
212    // Prove the copy opens before `serve` gets it. A copy that does not open is
213    // a hard failure, not a silent empty start — and because it opened in
214    // staging, nothing of it reaches `db_dir`.
215    GraphDb::<core_storage::fs::RealFs>::open(staging).map_err(|e| {
216        failed(format!(
217            "restore into {} from {} failed: the copy does not open: {e}",
218            db_dir.display(),
219            backup.display()
220        ))
221    })?;
222
223    // The copy is good. Move it in. A rename within one directory tree is the
224    // closest thing to atomic the filesystem offers; if one still fails, undo
225    // the moves already made so `db_dir` is left as it was found.
226    let mut moved: Vec<&String> = Vec::new();
227    for name in &files {
228        if let Err(e) = std::fs::rename(staging.join(name), db_dir.join(name)) {
229            for done in &moved {
230                let _ = std::fs::remove_file(db_dir.join(done));
231            }
232            return Err(failed(format!(
233                "restore into {} from {}: installing {name}: {e}",
234                db_dir.display(),
235                backup.display()
236            )));
237        }
238        moved.push(name);
239    }
240
241    Ok(RestoreOutcome::Restored {
242        from: backup.to_path_buf(),
243        files,
244        bytes,
245    })
246}