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