1use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use corium_log::{FileLog, LogError, TransactionLog, TxRecord, VersionedLog};
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#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct BackupReport {
23 pub basis_t: u64,
25 pub index_basis_t: u64,
27 pub copied_blobs: usize,
29 pub reused_blobs: usize,
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct RestoreReport {
36 pub source_db: String,
38 pub target_db: String,
40 pub basis_t: u64,
42 pub copied_blobs: usize,
44 pub reused_blobs: usize,
46}
47
48#[derive(Debug, Error)]
50pub enum BackupError {
51 #[error("backup I/O failed: {0}")]
53 Io(#[from] io::Error),
54 #[error(transparent)]
56 Store(#[from] StoreError),
57 #[error(transparent)]
59 Log(#[from] LogError),
60 #[error("database {0:?} does not exist")]
62 MissingDatabase(String),
63 #[error("invalid backup: {0}")]
65 Invalid(String),
66 #[error("database {0:?} already exists in the target")]
68 TargetExists(String),
69 #[error("storage format {found} is newer than supported format {supported}")]
71 UnsupportedFormat {
72 found: u32,
74 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
148async fn put_root(store: &dyn RootStore, name: &str, bytes: &[u8]) -> Result<(), StoreError> {
149 loop {
150 let previous = store.get_root(name).await?;
151 if previous.as_deref() == Some(bytes) {
152 return Ok(());
153 }
154 match store.cas_root(name, previous.as_deref(), bytes).await {
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 write_log_atomically(records: &[TxRecord], target: &Path) -> Result<(), BackupError> {
173 if let Some(parent) = target.parent() {
174 fs::create_dir_all(parent)?;
175 }
176 let temporary = target.with_extension("tmp");
177 match fs::remove_file(&temporary) {
178 Ok(()) => {}
179 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
180 Err(error) => return Err(error.into()),
181 }
182 let log = FileLog::open(&temporary)?;
183 for record in records {
184 log.append(record)?;
185 }
186 fs::rename(temporary, target)?;
187 Ok(())
188}
189
190async fn copy_root_blobs(
191 source: &dyn BlobStore,
192 target: &dyn BlobStore,
193 root: &DbRoot,
194) -> Result<(usize, usize), BackupError> {
195 let mut copied = 0;
199 let mut reused = 0;
200 for id in root.roots.iter().flatten() {
201 if target.contains(id).await? {
202 reused += 1;
203 continue;
204 }
205 let bytes = source
206 .get(id)
207 .await?
208 .ok_or_else(|| BackupError::Invalid(format!("root references missing blob {id}")))?;
209 let copied_id = target.put(&bytes).await?;
210 if copied_id != *id {
211 return Err(BackupError::Invalid(format!(
212 "blob digest changed while copying {id}"
213 )));
214 }
215 copied += 1;
216 }
217 Ok((copied, reused))
218}
219
220pub async fn backup(
230 source_data_dir: impl AsRef<Path>,
231 db: &str,
232 destination: impl AsRef<Path>,
233) -> Result<BackupReport, BackupError> {
234 if !valid_db_name(db) {
235 return Err(BackupError::MissingDatabase(db.to_owned()));
236 }
237 let source_data_dir = source_data_dir.as_ref();
238 let destination = destination.as_ref();
239 let source = FsStore::open(source_data_dir.join("store"))?;
240 let db_name = db_root_name(db);
241 let meta_name = meta_root_name(db);
242 let db_bytes = source
243 .get_root(&db_name)
244 .await?
245 .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
246 let meta = source
247 .get_root(&meta_name)
248 .await?
249 .ok_or_else(|| BackupError::MissingDatabase(db.to_owned()))?;
250 let root = DbRoot::decode(&db_bytes)
251 .ok_or_else(|| BackupError::Invalid("database root cannot be decoded".into()))?;
252 if root.format_version > FORMAT_VERSION {
253 return Err(BackupError::UnsupportedFormat {
254 found: root.format_version,
255 supported: FORMAT_VERSION,
256 });
257 }
258 let source_logs = source_data_dir.join("logs");
259 if !VersionedLog::exists(&source_logs, db) {
260 return Err(BackupError::Invalid("transaction log is missing".into()));
261 }
262 let records = VersionedLog::open_read_only(&source_logs, db)?.replay()?;
266 let basis_t = records.last().map_or(0, |record| record.t);
267 if root.index_basis_t > basis_t {
268 return Err(BackupError::Invalid(format!(
269 "index basis {} is ahead of log basis {basis_t}",
270 root.index_basis_t
271 )));
272 }
273
274 fs::create_dir_all(destination)?;
275 let target = FsStore::open(destination.join("store"))?;
276 let (copied_blobs, reused_blobs) = copy_root_blobs(&source, &target, &root).await?;
277 put_root(&target, &db_name, &db_bytes).await?;
278 put_root(&target, &meta_name, &meta).await?;
279 write_log_atomically(&records, &log_path(destination, db))?;
280 let manifest = Manifest {
281 source_db: db.to_owned(),
282 basis_t,
283 index_basis_t: root.index_basis_t,
284 format_version: root.format_version,
285 };
286 let manifest_tmp = destination.join("manifest.tmp");
287 fs::write(&manifest_tmp, manifest.encode())?;
288 fs::rename(manifest_tmp, destination.join(MANIFEST_NAME))?;
289 Ok(BackupReport {
290 basis_t,
291 index_basis_t: root.index_basis_t,
292 copied_blobs,
293 reused_blobs,
294 })
295}
296
297pub async fn restore(
307 source: impl AsRef<Path>,
308 target_data_dir: impl AsRef<Path>,
309 target_db: &str,
310) -> Result<RestoreReport, BackupError> {
311 if !valid_db_name(target_db) {
312 return Err(BackupError::Invalid(format!(
313 "invalid target database name {target_db:?}"
314 )));
315 }
316 let source = source.as_ref();
317 let target_data_dir = target_data_dir.as_ref();
318 let manifest = Manifest::decode(&fs::read(source.join(MANIFEST_NAME))?)?;
319 let source_store = FsStore::open(source.join("store"))?;
320 let source_db_name = db_root_name(&manifest.source_db);
321 let source_meta_name = meta_root_name(&manifest.source_db);
322 let db_bytes = source_store
323 .get_root(&source_db_name)
324 .await?
325 .ok_or_else(|| BackupError::Invalid("database root is missing".into()))?;
326 let meta = source_store
327 .get_root(&source_meta_name)
328 .await?
329 .ok_or_else(|| BackupError::Invalid("metadata root is missing".into()))?;
330 let root = DbRoot::decode(&db_bytes)
331 .ok_or_else(|| BackupError::Invalid("database root cannot be decoded".into()))?;
332 if root.index_basis_t != manifest.index_basis_t
333 || root.format_version != manifest.format_version
334 {
335 return Err(BackupError::Invalid(
336 "manifest does not match the database root".into(),
337 ));
338 }
339 let source_log = log_path(source, &manifest.source_db);
340 if !source_log.is_file() {
341 return Err(BackupError::Invalid("transaction log is missing".into()));
342 }
343 let log = FileLog::open(&source_log)?;
344 let actual_basis = log.replay()?.last().map_or(0, |record| record.t);
345 if actual_basis != manifest.basis_t {
346 return Err(BackupError::Invalid(format!(
347 "manifest basis {} does not match log basis {actual_basis}",
348 manifest.basis_t
349 )));
350 }
351
352 let target_store = FsStore::open(target_data_dir.join("store"))?;
353 let target_db_name = db_root_name(target_db);
354 let target_meta_name = meta_root_name(target_db);
355 if target_store.get_root(&target_db_name).await?.is_some()
356 || target_store.get_root(&target_meta_name).await?.is_some()
357 || log_path(target_data_dir, target_db).exists()
358 {
359 return Err(BackupError::TargetExists(target_db.to_owned()));
360 }
361 let (copied_blobs, reused_blobs) = copy_root_blobs(&source_store, &target_store, &root).await?;
362 let target_log = log_path(target_data_dir, target_db);
363 copy_file_atomically(&source_log, &target_log)?;
364 let publish = match target_store
367 .cas_root(&target_db_name, None, &db_bytes)
368 .await
369 {
370 Ok(()) => target_store.cas_root(&target_meta_name, None, &meta).await,
371 Err(error) => Err(error),
372 };
373 if let Err(error) = publish {
374 let _ = target_store.delete_root(&target_db_name).await;
375 let _ = target_store.delete_root(&target_meta_name).await;
376 let _ = fs::remove_file(&target_log);
377 return Err(error.into());
378 }
379 Ok(RestoreReport {
380 source_db: manifest.source_db,
381 target_db: target_db.to_owned(),
382 basis_t: manifest.basis_t,
383 copied_blobs,
384 reused_blobs,
385 })
386}