Skip to main content

corium_transactor/
backup.rs

1//! Offline, content-addressed database backup and restore.
2//!
3//! A backup directory is itself a Corium blob/root store plus a durable log
4//! and a small manifest. Re-running [`backup`] against the same directory is
5//! incremental: immutable blobs already present are not copied.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use corium_log::{FileLog, LogError, TransactionLog};
12use corium_store::{
13    BlobStore, DbRoot, FORMAT_VERSION, FsStore, RootStore, StoreError, db_root_name,
14};
15use thiserror::Error;
16
17const MANIFEST_NAME: &str = "manifest";
18const MANIFEST_MAGIC: &str = "corium-backup-v1";
19
20/// Counters and snapshot identity returned by a backup.
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct BackupReport {
23    /// Transaction basis preserved by the snapshot.
24    pub basis_t: u64,
25    /// Index basis named by the copied root.
26    pub index_basis_t: u64,
27    /// Immutable blobs newly copied during this run.
28    pub copied_blobs: usize,
29    /// Immutable blobs already present in an incremental destination.
30    pub reused_blobs: usize,
31}
32
33/// Counters and identity returned by a restore.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct RestoreReport {
36    /// Source database name recorded in the backup.
37    pub source_db: String,
38    /// Restored database name (which may differ for clone restores).
39    pub target_db: String,
40    /// Restored transaction basis.
41    pub basis_t: u64,
42    /// Immutable blobs newly copied into the target store.
43    pub copied_blobs: usize,
44    /// Immutable blobs already shared by the target store.
45    pub reused_blobs: usize,
46}
47
48/// Backup or restore failure.
49#[derive(Debug, Error)]
50pub enum BackupError {
51    /// Filesystem operation failed.
52    #[error("backup I/O failed: {0}")]
53    Io(#[from] io::Error),
54    /// Blob/root store operation failed.
55    #[error(transparent)]
56    Store(#[from] StoreError),
57    /// Transaction log could not be read.
58    #[error(transparent)]
59    Log(#[from] LogError),
60    /// The requested database does not exist.
61    #[error("database {0:?} does not exist")]
62    MissingDatabase(String),
63    /// A required root, blob, log, or manifest is malformed/missing.
64    #[error("invalid backup: {0}")]
65    Invalid(String),
66    /// Restore refuses to replace an existing database.
67    #[error("database {0:?} already exists in the target")]
68    TargetExists(String),
69    /// The stored format cannot be read by this binary.
70    #[error("storage format {found} is newer than supported format {supported}")]
71    UnsupportedFormat {
72        /// Version found in the backup.
73        found: u32,
74        /// Newest version understood by this binary.
75        supported: u32,
76    },
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80struct Manifest {
81    source_db: String,
82    basis_t: u64,
83    index_basis_t: u64,
84    format_version: u32,
85}
86
87impl Manifest {
88    fn encode(&self) -> String {
89        format!(
90            "{MANIFEST_MAGIC}\nsource-db={}\nbasis-t={}\nindex-basis-t={}\nformat-version={}\n",
91            self.source_db, self.basis_t, self.index_basis_t, self.format_version
92        )
93    }
94
95    fn decode(bytes: &[u8]) -> Result<Self, BackupError> {
96        let text = std::str::from_utf8(bytes)
97            .map_err(|_| BackupError::Invalid("manifest is not UTF-8".into()))?;
98        let mut lines = text.lines();
99        if lines.next() != Some(MANIFEST_MAGIC) {
100            return Err(BackupError::Invalid("unknown manifest header".into()));
101        }
102        let field = |line: Option<&str>, name: &str| -> Result<String, BackupError> {
103            line.and_then(|line| line.strip_prefix(&format!("{name}=")))
104                .map(str::to_owned)
105                .ok_or_else(|| BackupError::Invalid(format!("manifest has no {name}")))
106        };
107        let source_db = field(lines.next(), "source-db")?;
108        let basis_t = field(lines.next(), "basis-t")?
109            .parse()
110            .map_err(|_| BackupError::Invalid("manifest basis-t is invalid".into()))?;
111        let index_basis_t = field(lines.next(), "index-basis-t")?
112            .parse()
113            .map_err(|_| BackupError::Invalid("manifest index-basis-t is invalid".into()))?;
114        let format_version = field(lines.next(), "format-version")?
115            .parse()
116            .map_err(|_| BackupError::Invalid("manifest format-version is invalid".into()))?;
117        if format_version > FORMAT_VERSION {
118            return Err(BackupError::UnsupportedFormat {
119                found: format_version,
120                supported: FORMAT_VERSION,
121            });
122        }
123        Ok(Self {
124            source_db,
125            basis_t,
126            index_basis_t,
127            format_version,
128        })
129    }
130}
131
132fn valid_db_name(name: &str) -> bool {
133    !name.is_empty()
134        && name.len() <= 128
135        && name
136            .bytes()
137            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
138}
139
140fn meta_root_name(db: &str) -> String {
141    format!("meta:{db}")
142}
143
144fn log_path(data_dir: &Path, db: &str) -> PathBuf {
145    data_dir.join("logs").join(format!("{db}.log"))
146}
147
148fn put_root(store: &dyn RootStore, name: &str, bytes: &[u8]) -> Result<(), StoreError> {
149    loop {
150        let previous = store.get_root(name)?;
151        if previous.as_deref() == Some(bytes) {
152            return Ok(());
153        }
154        match store.cas_root(name, previous.as_deref(), bytes) {
155            Ok(()) => return Ok(()),
156            Err(StoreError::CasFailed { .. }) => {}
157            Err(error) => return Err(error),
158        }
159    }
160}
161
162fn copy_file_atomically(source: &Path, target: &Path) -> Result<(), io::Error> {
163    if let Some(parent) = target.parent() {
164        fs::create_dir_all(parent)?;
165    }
166    let temporary = target.with_extension("tmp");
167    fs::copy(source, &temporary)?;
168    fs::rename(temporary, target)
169}
170
171fn copy_root_blobs(
172    source: &dyn BlobStore,
173    target: &dyn BlobStore,
174    root: &DbRoot,
175) -> Result<(usize, usize), BackupError> {
176    // v1 index publications store each covering index as one flat blob. When
177    // index roots become tree nodes, this must share the recursive child walk
178    // used by GC so backup and reachability cannot diverge.
179    let mut copied = 0;
180    let mut reused = 0;
181    for id in root.roots.iter().flatten() {
182        if target.contains(id)? {
183            reused += 1;
184            continue;
185        }
186        let bytes = source
187            .get(id)?
188            .ok_or_else(|| BackupError::Invalid(format!("root references missing blob {id}")))?;
189        let copied_id = target.put(&bytes)?;
190        if copied_id != *id {
191            return Err(BackupError::Invalid(format!(
192                "blob digest changed while copying {id}"
193            )));
194        }
195        copied += 1;
196    }
197    Ok((copied, reused))
198}
199
200/// Copies one offline database into `destination`.
201///
202/// `source_data_dir` is a transactor data directory. The transactor must be
203/// stopped so the root, metadata, and log form one stable snapshot. Reusing a
204/// destination performs an incremental backup and copies only absent blobs.
205///
206/// # Errors
207/// Returns an error if the database is missing, the snapshot is inconsistent,
208/// or files cannot be read/written.
209pub fn backup(
210    source_data_dir: impl AsRef<Path>,
211    db: &str,
212    destination: impl AsRef<Path>,
213) -> Result<BackupReport, BackupError> {
214    if !valid_db_name(db) {
215        return Err(BackupError::MissingDatabase(db.to_owned()));
216    }
217    let source_data_dir = source_data_dir.as_ref();
218    let destination = destination.as_ref();
219    let source = FsStore::open(source_data_dir.join("store"))?;
220    let db_name = db_root_name(db);
221    let meta_name = meta_root_name(db);
222    let db_bytes = source
223        .get_root(&db_name)?
224        .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
225    let meta = source
226        .get_root(&meta_name)?
227        .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
228    let root = DbRoot::decode(&db_bytes)
229        .ok_or_else(|| BackupError::Invalid("database root cannot be decoded".into()))?;
230    if root.format_version > FORMAT_VERSION {
231        return Err(BackupError::UnsupportedFormat {
232            found: root.format_version,
233            supported: FORMAT_VERSION,
234        });
235    }
236    let source_log_path = log_path(source_data_dir, db);
237    if !source_log_path.is_file() {
238        return Err(BackupError::Invalid("transaction log is missing".into()));
239    }
240    let log = FileLog::open(&source_log_path)?;
241    let basis_t = log.replay()?.last().map_or(0, |record| record.t);
242    if root.index_basis_t > basis_t {
243        return Err(BackupError::Invalid(format!(
244            "index basis {} is ahead of log basis {basis_t}",
245            root.index_basis_t
246        )));
247    }
248
249    fs::create_dir_all(destination)?;
250    let target = FsStore::open(destination.join("store"))?;
251    let (copied_blobs, reused_blobs) = copy_root_blobs(&source, &target, &root)?;
252    put_root(&target, &db_name, &db_bytes)?;
253    put_root(&target, &meta_name, &meta)?;
254    copy_file_atomically(&source_log_path, &log_path(destination, db))?;
255    let manifest = Manifest {
256        source_db: db.to_owned(),
257        basis_t,
258        index_basis_t: root.index_basis_t,
259        format_version: root.format_version,
260    };
261    let manifest_tmp = destination.join("manifest.tmp");
262    fs::write(&manifest_tmp, manifest.encode())?;
263    fs::rename(manifest_tmp, destination.join(MANIFEST_NAME))?;
264    Ok(BackupReport {
265        basis_t,
266        index_basis_t: root.index_basis_t,
267        copied_blobs,
268        reused_blobs,
269    })
270}
271
272/// Restores a backup under `target_db`, allowing restore-as-clone by choosing
273/// a name different from the manifest's source database.
274///
275/// Existing target databases are never overwritten. Immutable blobs already
276/// present in the target store are shared rather than recopied.
277///
278/// # Errors
279/// Returns an error for malformed/incomplete backups, unsupported formats,
280/// existing targets, or I/O failures.
281pub fn restore(
282    source: impl AsRef<Path>,
283    target_data_dir: impl AsRef<Path>,
284    target_db: &str,
285) -> Result<RestoreReport, BackupError> {
286    if !valid_db_name(target_db) {
287        return Err(BackupError::Invalid(format!(
288            "invalid target database name {target_db:?}"
289        )));
290    }
291    let source = source.as_ref();
292    let target_data_dir = target_data_dir.as_ref();
293    let manifest = Manifest::decode(&fs::read(source.join(MANIFEST_NAME))?)?;
294    let source_store = FsStore::open(source.join("store"))?;
295    let source_db_name = db_root_name(&manifest.source_db);
296    let source_meta_name = meta_root_name(&manifest.source_db);
297    let db_bytes = source_store
298        .get_root(&source_db_name)?
299        .ok_or_else(|| BackupError::Invalid("database root is missing".into()))?;
300    let meta = source_store
301        .get_root(&source_meta_name)?
302        .ok_or_else(|| BackupError::Invalid("metadata root is missing".into()))?;
303    let root = DbRoot::decode(&db_bytes)
304        .ok_or_else(|| BackupError::Invalid("database root cannot be decoded".into()))?;
305    if root.index_basis_t != manifest.index_basis_t
306        || root.format_version != manifest.format_version
307    {
308        return Err(BackupError::Invalid(
309            "manifest does not match the database root".into(),
310        ));
311    }
312    let source_log = log_path(source, &manifest.source_db);
313    if !source_log.is_file() {
314        return Err(BackupError::Invalid("transaction log is missing".into()));
315    }
316    let log = FileLog::open(&source_log)?;
317    let actual_basis = log.replay()?.last().map_or(0, |record| record.t);
318    if actual_basis != manifest.basis_t {
319        return Err(BackupError::Invalid(format!(
320            "manifest basis {} does not match log basis {actual_basis}",
321            manifest.basis_t
322        )));
323    }
324
325    let target_store = FsStore::open(target_data_dir.join("store"))?;
326    let target_db_name = db_root_name(target_db);
327    let target_meta_name = meta_root_name(target_db);
328    if target_store.get_root(&target_db_name)?.is_some()
329        || target_store.get_root(&target_meta_name)?.is_some()
330        || log_path(target_data_dir, target_db).exists()
331    {
332        return Err(BackupError::TargetExists(target_db.to_owned()));
333    }
334    let (copied_blobs, reused_blobs) = copy_root_blobs(&source_store, &target_store, &root)?;
335    let target_log = log_path(target_data_dir, target_db);
336    copy_file_atomically(&source_log, &target_log)?;
337    // Metadata is the catalog entry, so publish it last. A node can never
338    // discover a partially restored database.
339    if let Err(error) = target_store
340        .cas_root(&target_db_name, None, &db_bytes)
341        .and_then(|()| target_store.cas_root(&target_meta_name, None, &meta))
342    {
343        let _ = target_store.delete_root(&target_db_name);
344        let _ = target_store.delete_root(&target_meta_name);
345        let _ = fs::remove_file(&target_log);
346        return Err(error.into());
347    }
348    Ok(RestoreReport {
349        source_db: manifest.source_db,
350        target_db: target_db.to_owned(),
351        basis_t: manifest.basis_t,
352        copied_blobs,
353        reused_blobs,
354    })
355}