Skip to main content

hayson_engine/database/
backup.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5
6use serde_json::{json, Value};
7
8use crate::atomic::write_atomic;
9use crate::catalog::{parse_catalog, Catalog};
10use crate::errors::HaysonSchemaError;
11use crate::lock::DirLock;
12use crate::storage::{partition_names_in, LOG_SUFFIX};
13
14use super::database::{Database, DatabaseError, HeldLock, LockMode};
15use super::layout::{Layout, LayoutOptions};
16use super::verify::{verify, VerifyOptions};
17
18/// The manifest a backup carries next to the files it copied.
19pub const BACKUP_MANIFEST: &str = "hayson-backup.json";
20const BACKUP_FORMAT: u64 = 1;
21const RESTORE_OLD: &str = ".restore-old";
22const RESTORE_NEW: &str = ".restore-new";
23
24#[derive(Debug, Clone)]
25pub struct BackupOptions {
26    /// How the writer lock is taken for the length of the copy. [`LockMode::Skip`] copies a database
27    /// a writer may be appending to, which can capture a half-written last line (the engine cuts
28    /// such a tail off on the next writable open) — but nothing worse.
29    pub lock: LockMode,
30    /// Also copy the `<table>.idx` snapshots. Off by default: they are caches the engine rebuilds
31    /// from the logs, so leaving them out makes a backup smaller and no less complete.
32    pub indexes: bool,
33    /// Tables whose data (log files, partitions, images, snapshot) is left out; their declarations
34    /// stay in the metadata document, so the copy opens with them empty. A name the database does
35    /// not declare is refused.
36    pub exclude_tables: Vec<String>,
37}
38
39impl Default for BackupOptions {
40    fn default() -> Self {
41        BackupOptions {
42            lock: LockMode::default(),
43            indexes: false,
44            exclude_tables: Vec::new(),
45        }
46    }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct BackupResult {
51    pub dest: PathBuf,
52    pub files: usize,
53    pub bytes: u64,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct RestoreOptions {
58    /// How the writer lock on the directory being restored into is taken. As in [`BackupOptions`].
59    pub lock: LockMode,
60    /// Refuses a backup holding an image file larger than this, before anything is replaced.
61    pub max_image_bytes: Option<u64>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RestoreResult {
66    pub dir: PathBuf,
67    pub files: usize,
68    pub bytes: u64,
69    /// Files of the database that was there before and were replaced.
70    pub replaced: usize,
71}
72
73pub(super) fn refuse(message: String) -> DatabaseError {
74    DatabaseError::Schema(HaysonSchemaError(message))
75}
76
77pub(super) fn read_catalog_of(layout: &Layout) -> Result<Catalog, DatabaseError> {
78    let path = layout.metadata_path();
79    let raw = fs::read_to_string(path).map_err(|error| {
80        if error.kind() == io::ErrorKind::NotFound {
81            refuse(format!(
82                "\"{}\" does not exist — there is no database to back up",
83                path.display()
84            ))
85        } else {
86            DatabaseError::Io(error)
87        }
88    })?;
89    let value: Value = serde_json::from_str(&raw)
90        .map_err(|error| DatabaseError::Catalog(format!("\"{}\" is not valid JSON: {error}", path.display())))?;
91    Ok(parse_catalog(&value)?)
92}
93
94fn relative_safely(root: &Path, path: &Path) -> Result<PathBuf, DatabaseError> {
95    let relative = path
96        .strip_prefix(root)
97        .map_err(|_| refuse(format!("\"{}\" is outside the database directory", path.display())))?;
98    check_relative(relative)?;
99    Ok(relative.to_path_buf())
100}
101
102/// A path a backup or a restore may join onto a root: relative, no `..`, no root, no prefix.
103pub(super) fn check_relative(path: &Path) -> Result<(), DatabaseError> {
104    let sane = path.components().all(|part| matches!(part, Component::Normal(_)));
105    if sane && path.components().next().is_some() {
106        Ok(())
107    } else {
108        Err(refuse(format!(
109            "\"{}\" is not a path inside the database directory",
110            path.display()
111        )))
112    }
113}
114
115fn files_in(dir: &Path) -> Result<Vec<PathBuf>, DatabaseError> {
116    let mut found = Vec::new();
117    let Ok(entries) = fs::read_dir(dir) else {
118        return Ok(found);
119    };
120    for entry in entries {
121        let entry = entry?;
122        let kind = entry.file_type()?;
123        if kind.is_symlink() {
124            return Err(refuse(format!(
125                "\"{}\" is a symbolic link — refusing to follow it",
126                entry.path().display()
127            )));
128        }
129        if kind.is_file() {
130            found.push(entry.path());
131        }
132    }
133    found.sort();
134    Ok(found)
135}
136
137/// Every file the database at `root` accounts for, as paths relative to `root`: its metadata
138/// document, the logs of its tables (one file, or one per partition), the files under each table's
139/// image directory and, when asked, the index snapshots. Anything else in the directory — another
140/// database in a subfolder, say — is not the database's and is left out.
141pub(super) fn owned_files(
142    root: &Path,
143    layout: &Layout,
144    catalog: &Catalog,
145    indexes: bool,
146    exclude: &[String],
147) -> Result<Vec<PathBuf>, DatabaseError> {
148    if let Some(unknown) = exclude.iter().find(|name| !catalog.tables.contains_key(*name)) {
149        return Err(refuse(format!(
150            "cannot leave out table \"{unknown}\" — the database declares no such table"
151        )));
152    }
153    let mut owned: BTreeSet<PathBuf> = BTreeSet::new();
154    let mut add = |path: &Path| -> Result<(), DatabaseError> {
155        owned.insert(relative_safely(root, path)?);
156        Ok(())
157    };
158    add(layout.metadata_path())?;
159    for (name, entry) in &catalog.tables {
160        if exclude.contains(name) {
161            continue;
162        }
163        let flat = layout.log_path(name);
164        match fs::symlink_metadata(&flat) {
165            Ok(meta) if meta.file_type().is_symlink() => {
166                return Err(refuse(format!(
167                    "\"{}\" is a symbolic link — refusing to follow it",
168                    flat.display()
169                )))
170            }
171            Ok(meta) if meta.is_file() => add(&flat)?,
172            _ => {}
173        }
174        let partitions = layout.tables().join(name);
175        for part in partition_names_in(&partitions) {
176            add(&partitions.join(format!("{part}{LOG_SUFFIX}")))?;
177        }
178        if indexes && layout.index_path(name).is_file() {
179            add(&layout.index_path(name))?;
180        }
181        let image_dir = entry
182            .options
183            .as_ref()
184            .and_then(|options| options.image_dir.clone())
185            .or_else(|| catalog.defaults.as_ref().and_then(|d| d.image_dir.clone()))
186            .map(|dir| root.join(dir))
187            .unwrap_or_else(|| layout.tables().join(format!("{name}.images")));
188        for file in files_in(&image_dir)? {
189            add(&file)?;
190        }
191    }
192    Ok(owned.into_iter().collect())
193}
194
195/// The flat log files of the tables `exclude` leaves out, as paths relative to `root`. They are
196/// packed or copied empty: the catalog still declares those tables, and an unpacked database whose
197/// declared table has no log at all does not open read-only.
198pub(super) fn emptied_logs(root: &Path, layout: &Layout, catalog: &Catalog, exclude: &[String]) -> Vec<PathBuf> {
199    exclude
200        .iter()
201        .filter(|name| {
202            catalog
203                .tables
204                .get(*name)
205                .is_some_and(|entry| entry.partition_by.is_none())
206        })
207        .filter_map(|name| relative_safely(root, &layout.log_path(name)).ok())
208        .collect()
209}
210
211pub(super) fn hold(root: &Path, layout: &Layout, lock: &LockMode) -> Result<HeldLock, DatabaseError> {
212    let LockMode::Enabled(options) = lock else {
213        return Ok(HeldLock(None));
214    };
215    if root.is_dir() {
216        fs::create_dir_all(layout.runtime())?;
217    }
218    Ok(HeldLock(Some(DirLock::acquire_as(
219        layout.lock_path(),
220        options.clone(),
221        root.to_path_buf(),
222    )?)))
223}
224
225fn copy_synced(from: &Path, to: &Path) -> io::Result<u64> {
226    if let Some(parent) = to.parent() {
227        fs::create_dir_all(parent)?;
228    }
229    let bytes = fs::copy(from, to)?;
230    fs::File::open(to)?.sync_all()?;
231    Ok(bytes)
232}
233
234/// Copies a database directory into `dest`: the files the database accounts for (see
235/// [`owned_files`]) plus a `hayson-backup.json` manifest listing them with their sizes.
236///
237/// `dest` must not exist or must be an empty directory. The copy is built next to it and renamed
238/// into place, so a backup that fails halfway leaves no half-filled `dest`. The directory is held
239/// under the writer lock for the length of the copy unless `options.lock` is [`LockMode::Skip`];
240/// a database with an unfinished unit of work is refused, because its log and journal disagree
241/// until a writable open finishes it.
242///
243/// History kept in a database of its own is not part of this database's files, so it is not copied.
244pub fn backup_database(dir: &Path, dest: &Path, options: &BackupOptions) -> Result<BackupResult, DatabaseError> {
245    let layout = Database::resolve_layout(dir, &LayoutOptions::default())?;
246    let catalog = read_catalog_of(&layout)?;
247    if dest.exists()
248        && fs::read_dir(dest)
249            .map(|mut entries| entries.next().is_some())
250            .unwrap_or(true)
251    {
252        return Err(refuse(format!(
253            "\"{}\" already exists and is not an empty directory",
254            dest.display()
255        )));
256    }
257    let partial = sibling(dest, ".partial");
258    if partial.exists() {
259        fs::remove_dir_all(&partial)?;
260    }
261
262    let held = hold(dir, &layout, &options.lock)?;
263    let outcome = copy_database(dir, &layout, &catalog, &partial, options);
264    drop(held);
265    match outcome {
266        Ok(result) => {
267            if dest.exists() {
268                fs::remove_dir(dest)?;
269            }
270            fs::rename(&partial, dest)?;
271            Ok(BackupResult {
272                dest: dest.to_path_buf(),
273                ..result
274            })
275        }
276        Err(error) => {
277            let _ = fs::remove_dir_all(&partial);
278            Err(error)
279        }
280    }
281}
282
283pub(super) fn sibling(path: &Path, suffix: &str) -> PathBuf {
284    let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
285    name.push(suffix);
286    path.with_file_name(name)
287}
288
289fn copy_database(
290    root: &Path,
291    layout: &Layout,
292    catalog: &Catalog,
293    partial: &Path,
294    options: &BackupOptions,
295) -> Result<BackupResult, DatabaseError> {
296    if layout.journal_path().exists() {
297        return Err(refuse(format!(
298            "\"{}\" holds an unfinished unit of work — open the database for writing once so it is finished, then back it up",
299            layout.journal_path().display()
300        )));
301    }
302    let mut files = owned_files(root, layout, catalog, options.indexes, &options.exclude_tables)?;
303    let emptied = emptied_logs(root, layout, catalog, &options.exclude_tables);
304    files.extend(emptied.iter().cloned());
305    files.sort();
306    fs::create_dir_all(partial)?;
307    let mut listed = Vec::new();
308    let mut total = 0u64;
309    for relative in &files {
310        let bytes = if emptied.contains(relative) {
311            let target = partial.join(relative);
312            if let Some(parent) = target.parent() {
313                fs::create_dir_all(parent)?;
314            }
315            fs::File::create(&target)?.sync_all()?;
316            0
317        } else {
318            copy_synced(&root.join(relative), &partial.join(relative))?
319        };
320        total += bytes;
321        listed.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "bytes": bytes }));
322    }
323    let manifest = json!({
324        "format": BACKUP_FORMAT,
325        "createdAt": chrono::Utc::now().timestamp_millis(),
326        "engine": env!("CARGO_PKG_VERSION"),
327        "metadataFile": layout.metadata_file(),
328        "files": listed,
329    });
330    let mut text = serde_json::to_string_pretty(&manifest).map_err(|e| DatabaseError::Catalog(e.to_string()))?;
331    text.push('\n');
332    write_atomic(&partial.join(BACKUP_MANIFEST), text.as_bytes(), None)?;
333    Ok(BackupResult {
334        dest: partial.to_path_buf(),
335        files: files.len(),
336        bytes: total,
337    })
338}
339
340#[derive(Clone)]
341pub(super) struct ManifestEntry {
342    pub(super) relative: PathBuf,
343    pub(super) bytes: u64,
344}
345
346fn read_manifest(backup: &Path) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
347    let path = backup.join(BACKUP_MANIFEST);
348    let raw = fs::read_to_string(&path).map_err(|error| {
349        refuse(format!(
350            "\"{}\" is not a readable backup manifest: {error}",
351            path.display()
352        ))
353    })?;
354    parse_manifest(&raw, &path.display().to_string())
355}
356
357pub(super) fn parse_manifest(raw: &str, label: &str) -> Result<(String, Vec<ManifestEntry>), DatabaseError> {
358    let manifest: Value =
359        serde_json::from_str(raw).map_err(|error| refuse(format!("\"{label}\" is not valid JSON: {error}")))?;
360    if manifest.get("format").and_then(Value::as_u64) != Some(BACKUP_FORMAT) {
361        return Err(refuse(format!("\"{label}\" is not a backup format this engine reads")));
362    }
363    let metadata_file = manifest
364        .get("metadataFile")
365        .and_then(Value::as_str)
366        .ok_or_else(|| refuse("backup manifest names no \"metadataFile\"".to_string()))?
367        .to_string();
368    let listed = manifest
369        .get("files")
370        .and_then(Value::as_array)
371        .ok_or_else(|| refuse("backup manifest lists no \"files\"".to_string()))?;
372    let mut entries = Vec::new();
373    for item in listed {
374        let relative = PathBuf::from(
375            item.get("path")
376                .and_then(Value::as_str)
377                .ok_or_else(|| refuse("a backup manifest entry has no \"path\"".to_string()))?,
378        );
379        check_relative(&relative)?;
380        let bytes = item
381            .get("bytes")
382            .and_then(Value::as_u64)
383            .ok_or_else(|| refuse(format!("manifest entry \"{}\" has no \"bytes\"", relative.display())))?;
384        entries.push(ManifestEntry { relative, bytes });
385    }
386    Ok((metadata_file, entries))
387}
388
389/// Restores a backup made by [`backup_database`] into `dir`, replacing the database that is there.
390///
391/// Recoverable: nothing is deleted until the replacement is in place. The backup is copied into
392/// `<dir>/.restore-new` and checked (sizes against the manifest, then [`verify`] with no error);
393/// the files of the database being replaced are moved into `<dir>/.restore-old`; the new files are
394/// moved in; and only then is `.restore-old` removed. If a move fails, the files already moved are
395/// put back, and when even that fails both errors are reported. A `.restore-old` left by an
396/// interrupted restore is refused, because it may hold the only good copy.
397///
398/// Only the files the database accounts for are replaced, so another database living in a
399/// subfolder of `dir` is untouched.
400pub fn restore_database(backup: &Path, dir: &Path, options: &RestoreOptions) -> Result<RestoreResult, DatabaseError> {
401    let (metadata_file, entries) = read_manifest(backup)?;
402    for entry in &entries {
403        let path = backup.join(&entry.relative);
404        let meta = fs::symlink_metadata(&path).map_err(|error| {
405            refuse(format!(
406                "backup file \"{}\" is missing: {error}",
407                entry.relative.display()
408            ))
409        })?;
410        if !meta.is_file() || meta.len() != entry.bytes {
411            return Err(refuse(format!(
412                "backup file \"{}\" does not match its manifest entry ({} bytes expected)",
413                entry.relative.display(),
414                entry.bytes
415            )));
416        }
417    }
418    let part = RestorePart {
419        sub: PathBuf::new(),
420        metadata_file,
421        entries: entries.clone(),
422    };
423    restore_staged(dir, &[part], &options.lock, options.max_image_bytes, |staged| {
424        let mut total = 0u64;
425        for entry in &entries {
426            total += copy_synced(&backup.join(&entry.relative), &staged.join(&entry.relative))?;
427        }
428        Ok(total)
429    })
430}
431
432/// One database of a restore: where it lives under the directory being restored into (`sub`, empty
433/// for the directory itself), its metadata file name, and its files as paths relative to that
434/// directory (already prefixed with `sub`).
435pub(super) struct RestorePart {
436    pub(super) sub: PathBuf,
437    pub(super) metadata_file: String,
438    pub(super) entries: Vec<ManifestEntry>,
439}
440
441/// The recoverable half of a restore, shared with unpacking: `stage` fills the given staging
442/// directory with the files of every part (returning their byte total) while the writer lock of each
443/// database is held. All parts are staged and verified before any of them is swapped in, and the
444/// swap moves every part's files through one `.restore-old`, so a failure puts all of them back.
445pub(super) fn restore_staged(
446    dir: &Path,
447    parts: &[RestorePart],
448    lock: &LockMode,
449    max_image_bytes: Option<u64>,
450    stage: impl FnOnce(&Path) -> Result<u64, DatabaseError>,
451) -> Result<RestoreResult, DatabaseError> {
452    let old = dir.join(RESTORE_OLD);
453    if old.exists() {
454        return Err(refuse(format!(
455            "\"{}\" exists — an earlier restore was interrupted and it may hold the only good copy of the database; move its files back (or delete it) before restoring again",
456            old.display()
457        )));
458    }
459    let created = !dir.exists();
460    let mut layouts = Vec::new();
461    let mut held = Vec::new();
462    for part in parts {
463        let root = dir.join(&part.sub);
464        fs::create_dir_all(&root)?;
465        let layout = Database::resolve_layout(
466            &root,
467            &LayoutOptions {
468                metadata_file: Some(part.metadata_file.clone()),
469                ..LayoutOptions::default()
470            },
471        )?;
472        held.push(hold(&root, &layout, lock)?);
473        layouts.push(layout);
474    }
475    let staged = dir.join(RESTORE_NEW);
476    let outcome = install(dir, &layouts, parts, &staged, &old, max_image_bytes, stage);
477    let _ = fs::remove_dir_all(&staged);
478    drop(held);
479    if created && outcome.is_err() {
480        // NOTE: a directory this call made and nothing was installed into must not outlive the refusal.
481        for layout in &layouts {
482            let _ = fs::remove_dir_all(layout.runtime());
483        }
484        let _ = fs::remove_dir(dir);
485    }
486    outcome
487}
488
489fn install(
490    root: &Path,
491    layouts: &[Layout],
492    parts: &[RestorePart],
493    staged: &Path,
494    old: &Path,
495    max_image_bytes: Option<u64>,
496    stage: impl FnOnce(&Path) -> Result<u64, DatabaseError>,
497) -> Result<RestoreResult, DatabaseError> {
498    if staged.exists() {
499        fs::remove_dir_all(staged)?;
500    }
501    let total = stage(staged)?;
502    for part in parts {
503        let report = verify(
504            &staged.join(&part.sub),
505            &VerifyOptions {
506                relations: false,
507                max_image_bytes,
508                ..VerifyOptions::default()
509            },
510        );
511        let which = if part.sub.as_os_str().is_empty() {
512            String::new()
513        } else {
514            format!(" (database \"{}\")", part.sub.display())
515        };
516        if let Some(problem) = report.problems.iter().find(|p| p.code == "image-oversize") {
517            return Err(refuse(format!(
518                "the backup holds an image over the size limit{which} ({}) — nothing was changed",
519                problem.message
520            )));
521        }
522        if let Some(problem) = report
523            .problems
524            .iter()
525            .find(|p| p.severity == super::verify::Severity::Error)
526        {
527            return Err(refuse(format!(
528                "the backup does not verify{which} ({}: {}) — nothing was changed",
529                problem.code, problem.message
530            )));
531        }
532    }
533
534    // The files of the databases now there: what each catalog accounts for (when it can be read),
535    // plus whatever the backup is about to overwrite.
536    let entries: Vec<&ManifestEntry> = parts.iter().flat_map(|part| part.entries.iter()).collect();
537    let mut replaced: BTreeSet<PathBuf> = entries.iter().map(|e| e.relative.clone()).collect();
538    for (part, layout) in parts.iter().zip(layouts) {
539        let part_root = root.join(&part.sub);
540        if let Ok(catalog) = read_catalog_of(layout) {
541            if let Ok(current) = owned_files(&part_root, layout, &catalog, true, &[]) {
542                replaced.extend(current.into_iter().map(|relative| part.sub.join(relative)));
543            }
544        }
545    }
546    replaced.retain(|relative| root.join(relative).is_file());
547
548    fs::create_dir_all(old)?;
549    let mut moved_out: Vec<PathBuf> = Vec::new();
550    let mut moved_in: Vec<PathBuf> = Vec::new();
551    let swap = (|| -> io::Result<()> {
552        for relative in &replaced {
553            move_file(&root.join(relative), &old.join(relative))?;
554            moved_out.push(relative.clone());
555        }
556        for entry in &entries {
557            move_file(&staged.join(&entry.relative), &root.join(&entry.relative))?;
558            moved_in.push(entry.relative.clone());
559        }
560        Ok(())
561    })();
562    match swap {
563        Ok(()) => {
564            fs::remove_dir_all(old)?;
565            Ok(RestoreResult {
566                dir: root.to_path_buf(),
567                files: entries.len(),
568                bytes: total,
569                replaced: replaced.len(),
570            })
571        }
572        Err(error) => {
573            let rollback = (|| -> io::Result<()> {
574                for relative in &moved_in {
575                    fs::remove_file(root.join(relative))?;
576                }
577                for relative in &moved_out {
578                    move_file(&old.join(relative), &root.join(relative))?;
579                }
580                Ok(())
581            })();
582            match rollback {
583                Ok(()) => {
584                    let _ = fs::remove_dir_all(old);
585                    Err(DatabaseError::Io(error))
586                }
587                Err(rollback_error) => Err(refuse(format!(
588                    "restore failed ({error}) and putting the previous files back failed too ({rollback_error}) — the previous database is in \"{}\"",
589                    old.display()
590                ))),
591            }
592        }
593    }
594}
595
596fn move_file(from: &Path, to: &Path) -> io::Result<()> {
597    if let Some(parent) = to.parent() {
598        fs::create_dir_all(parent)?;
599    }
600    fs::rename(from, to)
601}