Skip to main content

tuwunel_database/engine/
backup.rs

1use std::path::Path;
2
3use rocksdb::backup::{BackupEngine, BackupEngineInfo, BackupEngineOptions, RestoreOptions};
4use tuwunel_core::{
5	Config, Err, Result, err, error, implement, info, itertools::Itertools,
6	utils::time::rfc2822_from_seconds, warn,
7};
8
9use super::Engine;
10use crate::{Context, util::map_err};
11
12/// Creates a RocksDB backup of the current database.
13///
14/// Writable engines flush before snapshotting, while read-only engines back up
15/// their current view without a flush. Old backups are then purged to the
16/// configured retention count; a purge failure is logged without failing the
17/// newly created backup.
18///
19/// # Panics
20///
21/// Panics if RocksDB reports successful creation without returning metadata for
22/// the new backup.
23#[implement(Engine)]
24#[tracing::instrument(level = "debug", skip(self))]
25pub fn backup(&self) -> Result {
26	let to_keep = self.ctx.server.config.database_backups_to_keep;
27
28	if to_keep <= 0 {
29		return Err!(Config(
30			"database_backups_to_keep",
31			"Set above zero to enable backups; no backup was created."
32		));
33	}
34
35	let mut engine = backup_engine(&self.ctx)?;
36	let flush = !self.is_read_only();
37
38	engine
39		.create_new_backup_flush(&self.db, flush)
40		.map_err(map_err)?;
41
42	let backups = engine.get_backup_info();
43	let backup = backups
44		.last()
45		.expect("backup engine info is not empty");
46
47	info!(
48		backup_id = backup.backup_id,
49		size = backup.size,
50		num_files = backup.num_files,
51		"Created database backup"
52	);
53
54	engine
55		.purge_old_backups(usize::try_from(to_keep)?)
56		.inspect_err(|e| error!(?e, "Failed to purge old backup"))
57		.ok();
58
59	Ok(())
60}
61
62/// Deletes old backups while retaining the newest `keep` entries.
63///
64/// Passing zero removes every backup known to the backup engine. Retention and
65/// deletion are delegated to RocksDB's backup repository.
66#[implement(Engine)]
67#[tracing::instrument(level = "debug", skip(self))]
68pub fn backup_purge(&self, keep: usize) -> Result {
69	let mut engine = backup_engine(&self.ctx)?;
70
71	engine.purge_old_backups(keep).map_err(map_err)
72}
73
74/// Lists available backups as human-readable summary lines.
75///
76/// Each line includes the backup identifier, timestamp, byte size, and file
77/// count. An empty backup repository returns an error instead of an empty
78/// iterator.
79#[implement(Engine)]
80pub fn backup_list(&self) -> Result<impl Iterator<Item = String> + Send> {
81	let info = backup_engine(&self.ctx)?.get_backup_info();
82
83	if info.is_empty() {
84		return Err!("No backups found.");
85	}
86
87	let list = info.into_iter().map(|info| {
88		format!(
89			"#{} {}: {} bytes, {} files",
90			info.backup_id,
91			rfc2822_from_seconds(info.timestamp),
92			info.size,
93			info.num_files,
94		)
95	});
96
97	Ok(list)
98}
99
100/// Returns the number of backups currently recorded.
101///
102/// The count comes from RocksDB's backup metadata. An empty repository produces
103/// zero.
104#[implement(Engine)]
105pub fn backup_count(&self) -> Result<usize> {
106	let info = backup_engine(&self.ctx)?.get_backup_info();
107
108	Ok(info.len())
109}
110
111/// Verifies the integrity of a RocksDB backup.
112///
113/// Identifier zero selects the most recent backup; any other identifier selects
114/// that exact entry. The verified backup identifier is returned on success.
115#[implement(Engine)]
116pub fn backup_verify(&self, backup_id: u32) -> Result<u32> {
117	let engine = backup_engine(&self.ctx)?;
118	let backup = find_backup(&engine, backup_id)?;
119
120	engine
121		.verify_backup(backup.backup_id)
122		.map_err(map_err)?;
123
124	Ok(backup.backup_id)
125}
126
127/// Restore a backup over the configured database path, replacing the database
128/// files found there. Must complete prior to opening the database.
129pub(crate) fn restore(ctx: &Context, backup_id: u32) -> Result {
130	let mut engine = backup_engine(ctx)?;
131	let backup = find_backup(&engine, backup_id)?;
132	let path = &ctx.server.config.database_path;
133
134	warn!(
135		backup_id = backup.backup_id,
136		timestamp = %rfc2822_from_seconds(backup.timestamp),
137		size = backup.size,
138		num_files = backup.num_files,
139		?path,
140		"Restoring database backup"
141	);
142
143	engine
144		.restore_from_backup(path, path, &RestoreOptions::default(), backup.backup_id)
145		.map_err(map_err)?;
146
147	info!(backup_id = backup.backup_id, "Restored database backup");
148
149	Ok(())
150}
151
152/// Backup ID 0 selects the most recent backup.
153fn find_backup(engine: &BackupEngine, backup_id: u32) -> Result<BackupEngineInfo> {
154	let mut backups = engine.get_backup_info();
155
156	if backups.is_empty() {
157		return Err!("No backups found.");
158	}
159
160	let found = match backup_id {
161		| 0 => backups.pop(),
162		| id => backups
163			.iter()
164			.position(|info| info.backup_id == id)
165			.map(|pos| backups.swap_remove(pos)),
166	};
167
168	found.ok_or_else(|| {
169		let available = backups
170			.iter()
171			.map(|info| info.backup_id)
172			.join(", ");
173
174		err!("Backup #{backup_id} not found; available: {available}")
175	})
176}
177
178fn backup_engine(ctx: &Context) -> Result<BackupEngine> {
179	let path = backup_path(&ctx.server.config)?;
180	let options = BackupEngineOptions::new(path).map_err(map_err)?;
181
182	BackupEngine::open(&options, &*ctx.env.lock()?).map_err(map_err)
183}
184
185fn backup_path(config: &Config) -> Result<&Path> {
186	config
187		.database_backup_path
188		.as_deref()
189		.filter(|path| !path.as_os_str().is_empty())
190		.ok_or_else(|| err!(Config("database_backup_path", "Configure path to enable backups")))
191}