1use 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#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct BackupReport {
25 pub basis_t: u64,
27 pub index_basis_t: u64,
29 pub copied_blobs: usize,
31 pub reused_blobs: usize,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct RestoreReport {
38 pub source_db: String,
40 pub target_db: String,
42 pub basis_t: u64,
44 pub copied_blobs: usize,
46 pub reused_blobs: usize,
48}
49
50#[derive(Debug, Error)]
52pub enum BackupError {
53 #[error("backup I/O failed: {0}")]
55 Io(#[from] io::Error),
56 #[error(transparent)]
58 Store(#[from] StoreError),
59 #[error(transparent)]
61 Log(#[from] LogError),
62 #[error("database {0:?} does not exist")]
64 MissingDatabase(String),
65 #[error("invalid backup: {0}")]
67 Invalid(String),
68 #[error("database {0:?} already exists in the target")]
70 TargetExists(String),
71 #[error("storage format {found} is newer than supported format {supported}")]
73 UnsupportedFormat {
74 found: u32,
76 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
173fn 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
206fn 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
248pub 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 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
325pub 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 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}