feat(backup): support cross-platform background database backups

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-09-23 12:29:56 +08:00
1 parent 250e0c0a10
commit 2f960f104c
56 files changed
+4195 -962

No files matched your search

+2
View File
@@ -36,6 +36,8 @@ log = "0.4"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
fs2 = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
@@ -2350,6 +2350,29 @@ async fn save_export_destination_identity(
state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await
}
/// Read/delete operations must not trust a replaced backup mount or create a missing directory.
pub async fn verify_export_destination_identity(
state: &crate::connection::AppState,
dir: &std::path::Path,
) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!("Backup directory is unavailable: {}", dir.display()));
}
let recorded = state
.storage
.load_state(&export_destination_state_key(dir))
.await?
.map(|(bytes, _)| ExportDestinationIdentity::decode(&bytes))
.transpose()?
.flatten();
if recorded.as_ref().is_some_and(|identity| {
recorded_export_destination_identity_mismatch(identity, export_destination_identity_for_path(dir).as_ref())
}) {
return Err(format!("Backup directory now resolves to a different filesystem: {}", dir.display()));
}
Ok(())
}
/// Returns whether this macOS destination still has the transient, untagged
/// `st_dev` identity written by DBX versions before persistent volume UUIDs
/// were introduced. The caller must require an explicit directory selection
@@ -2692,6 +2715,19 @@ fn export_source_file_name(file_path: &str) -> String {
format!("{stem}.sql")
}
tokio::task_local! {
static TRACKED_BACKUP_DESTINATION: (std::path::PathBuf, Arc<std::sync::atomic::AtomicBool>);
}
/// Tracks successful create_new separately from preparation errors and path collisions.
pub(crate) async fn track_backup_destination<F: std::future::Future>(
path: std::path::PathBuf,
created: Arc<std::sync::atomic::AtomicBool>,
operation: F,
) -> F::Output {
TRACKED_BACKUP_DESTINATION.scope((path, created), operation).await
}
async fn create_database_export_writer(
state: &Arc<crate::connection::AppState>,
request: &DatabaseExportRequest,
@@ -2744,6 +2780,13 @@ async fn create_database_export_writer(
// at the same path in between. Re-check the identity of the handle we
// actually opened, not just the path, and refuse to keep a backup that
// landed on the wrong filesystem. See #6327.
if request.prevent_overwrite {
let _ = TRACKED_BACKUP_DESTINATION.try_with(|(path, created)| {
if path == std::path::Path::new(&request.file_path) {
created.store(true, std::sync::atomic::Ordering::Relaxed);
}
});
}
let opened_destination_identity = export_destination_identity_for_file(&file);
if export_destination_identity_mismatch(
expected_destination_identity.as_ref(),
@@ -4253,6 +4296,37 @@ mod tests {
assert_eq!(output, "SELECT 1;\n");
}
#[tokio::test]
async fn scheduled_backup_destination_tracking_grants_ownership_only_after_create_new() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("tracked.sql");
let state = Arc::new(test_app_state(directory.path()).await);
let mut request = export_request(true, true, true, Vec::new());
request.file_path = path.to_string_lossy().into_owned();
request.prevent_overwrite = true;
let created = Arc::new(std::sync::atomic::AtomicBool::new(false));
let writer = super::track_backup_destination(
path.clone(),
created.clone(),
create_database_export_writer(&state, &request),
)
.await
.unwrap();
assert!(created.load(std::sync::atomic::Ordering::Relaxed));
drop(writer);
std::fs::write(&path, b"keep existing").unwrap();
let collision = Arc::new(std::sync::atomic::AtomicBool::new(false));
assert!(super::track_backup_destination(
path.clone(),
collision.clone(),
create_database_export_writer(&state, &request)
)
.await
.is_err());
assert!(!collision.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(std::fs::read(path).unwrap(), b"keep existing");
}
#[tokio::test]
async fn backup_writer_does_not_overwrite_an_existing_file() {
let directory = tempfile::tempdir().unwrap();
+1
View File
@@ -81,6 +81,7 @@ pub use persistence::saved_sql;
pub use query::query_cancel;
pub use query::redis_ops;
pub use safety::risk_metrics;
pub mod scheduled_backup;
pub mod schema;
pub use connection::session_credentials;
pub use connection::task_supervisor;
@@ -0,0 +1,437 @@
use chrono::Utc;
use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use super::{models::*, BackupService};
use crate::{
connection::AppState,
database_export::{self, DatabaseExportOutputCompression, DatabaseExportRequest, ExportStatus},
models::connection::DatabaseType,
query, schema,
};
impl BackupService {
pub(crate) async fn serve(&self, stop: CancellationToken, drain: CancellationToken) {
let mut leader = None;
let mut migration_ready = false;
while !stop.is_cancelled() && !drain.is_cancelled() {
if !migration_ready {
migration_ready = match self.state.storage.inspect_data_migration().await {
Ok(status) => status.is_ready(),
Err(error) => {
log::warn!("[database-backup] waiting for data migration: {error}");
false
}
};
}
if migration_ready && leader.is_none() {
let acquired = std::fs::create_dir_all(&self.store.directory)
.and_then(|_| {
OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(self.store.directory.join("worker.lock"))
})
.ok()
.filter(|file| fs2::FileExt::try_lock_exclusive(file).is_ok());
if let Some(file) = acquired {
match self.store.recover().await {
Ok(()) => leader = Some(file),
Err(error) => log::error!("[database-backup] recovery failed: {error}"),
}
}
}
if migration_ready && leader.is_some() && !drain.is_cancelled() {
if let Err(error) = self.tick(&stop).await {
log::error!("[database-backup] scheduler failed: {error}");
}
}
tokio::select! { _ = stop.cancelled() => break, _ = drain.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(2)) => {} }
}
// Dropping the locked handle transfers leadership, including after an unclean process exit.
drop(leader);
}
async fn tick(&self, stop: &CancellationToken) -> Result<(), String> {
self.store.heartbeat().await?;
self.store.enqueue_due().await?;
let Some(job) = self.store.claim().await? else {
return Ok(());
};
let state = Arc::new(AppState::new_with_plugin_dir(
self.state.storage.clone(),
self.state.storage.data_dir().join("plugins"),
));
let cancellation = stop.child_token();
if self.store.cancelled(job.run.id.clone()).await? {
cancellation.cancel();
}
let (progress, receiver) = watch::channel(job.run.clone());
let service = self.clone();
let worker_state = state.clone();
let worker_cancel = cancellation.clone();
let run_id = job.run.id.clone();
let fallback = job.run.clone();
let runtime = tokio::runtime::Handle::current();
// SQL formatting and compression perform synchronous writes; keep them off async worker threads.
let mut worker = tokio::task::spawn_blocking(move || {
runtime.block_on(service.execute(job, worker_state, worker_cancel, progress))
});
let mut run = loop {
tokio::select! {
result = &mut worker => break match result {
Ok(run) => run,
Err(error) => { let mut run = receiver.borrow().clone(); run.status = "failed".into(); run.error = Some(format!("Backup worker failed: {error}")); break run; }
},
_ = tokio::time::sleep(Duration::from_millis(500)) => {
if stop.is_cancelled() || self.store.cancelled(run_id.clone()).await.unwrap_or(true) {
cancellation.cancel();
database_export::set_export_cancelled(&run_id).await;
}
if let Err(error) = self.store.heartbeat().await { log::error!("[database-backup] heartbeat failed: {error}"); cancellation.cancel(); }
let latest = receiver.borrow().clone();
if let Err(error) = self.store.progress(latest).await { log::error!("[database-backup] progress persistence failed: {error}"); cancellation.cancel(); }
}
}
};
database_export::clear_export_cancelled(&run_id).await;
state.shutdown(Duration::from_secs(3)).await;
if run.status != "success" {
if let Err(error) = self.cleanup(&mut run).await {
run.error = Some(format!(
"{}; partial files retained: {error}",
run.error.as_deref().unwrap_or("Backup cancelled")
));
}
}
run.completed_at = Some(Utc::now().to_rfc3339());
let success = run.status == "success";
self.store.finish(run).await?;
if success {
let snapshot = self.store.snapshot().await?;
if let Some(schedule) = snapshot.schedules.iter().find(|s| Some(&s.id) == fallback.schedule_id.as_ref()) {
let ids = snapshot
.runs
.iter()
.filter(|r| r.schedule_id.as_ref() == Some(&schedule.id) && r.status == "success")
.skip(schedule.retention_count)
.map(|r| r.id.clone())
.collect();
self.delete_runs(ids).await?;
}
}
Ok(())
}
async fn execute(
&self,
mut job: Job,
state: Arc<AppState>,
stop: CancellationToken,
progress: watch::Sender<BackupRun>,
) -> BackupRun {
let result = self.export_job(&mut job, &state, &stop, &progress).await;
job.run.status = if stop.is_cancelled() {
"cancelled"
} else if result.is_ok() {
"success"
} else {
"failed"
}
.into();
job.run.error = result.err();
if job.run.status == "success" {
job.run.progress_percent = 100.0;
}
job.run
}
async fn export_job(
&self,
job: &mut Job,
state: &Arc<AppState>,
stop: &CancellationToken,
progress: &watch::Sender<BackupRun>,
) -> Result<(), String> {
check_cancel(stop)?;
job.config.validate()?;
self.validate_destination(&job.config.destination_directory)?;
let connection = state
.storage
.load_connections()
.await?
.into_iter()
.find(|c| c.id == job.config.connection_id)
.ok_or("Saved backup connection is unavailable")?;
if !matches!(connection.db_type, DatabaseType::Mysql | DatabaseType::Postgres) {
return Err("This connection does not support consistent scheduled backups".into());
}
if !connection.save_password || connection.one_time {
return Err("Unattended backups require a saved connection and saved credentials".into());
}
let postgres = connection.db_type == DatabaseType::Postgres;
job.run.connection_name = connection.name.clone();
state.configs.write().await.insert(connection.id.clone(), connection);
check_cancel(stop)?;
let available: Vec<String> =
schema::list_databases_core(state, &job.config.connection_id).await?.into_iter().map(|d| d.name).collect();
let databases = if job.config.databases.is_empty() {
available.iter().filter(|name| !system_database(name, postgres)).cloned().collect::<Vec<_>>()
} else {
for name in &job.config.databases {
if !available.contains(name) {
return Err(format!("Configured backup database is unavailable: {name}"));
}
}
job.config.databases.clone()
};
if databases.is_empty() {
return Err("No databases are available for this backup".into());
}
let mut sensitive = true;
if !postgres && job.config.table_filter_mode != "all" {
if let Ok(result) = query::execute_sql_statement(
state,
&job.config.connection_id,
"",
"SHOW VARIABLES LIKE 'lower_case_table_names'",
None,
None,
)
.await
{
sensitive = !result
.rows
.first()
.and_then(|r| r.get(1))
.is_some_and(|v| matches!(v.as_str(), Some("1" | "2")) || matches!(v.as_i64(), Some(1 | 2)));
}
}
let directory = Path::new(&job.config.destination_directory).to_path_buf();
let output = if let Some(pattern) = &job.directory_pattern {
directory.join(render_template(pattern, &job.run, "", &job.time_zone, true)?)
} else {
directory
};
let mut included_count = 0;
for (db_index, database) in databases.iter().enumerate() {
check_cancel(stop)?;
let schemas = if postgres {
schema::list_schemas_core(state, &job.config.connection_id, database)
.await?
.into_iter()
.filter(|s| s != "information_schema" && !s.starts_with("pg_"))
.collect::<Vec<_>>()
} else {
vec![database.clone()]
};
if schemas.is_empty() {
return Err(format!("Database {database} has no exportable schemas"));
}
// Metadata discovery may be slow; finish it before starting the snapshot idle timeout.
let mut targets = Vec::new();
for schema_name in &schemas {
check_cancel(stop)?;
let mut selected_tables = Vec::new();
let mut excluded_tables = Vec::new();
if job.config.table_filter_mode != "all" {
let tables = schema::list_tables_core(
state,
&job.config.connection_id,
database,
schema_name,
None,
None,
None,
None,
None,
)
.await?;
for table in &tables {
let matched = job
.config
.table_patterns
.iter()
.any(|p| matches_pattern(p, &table.name, database, schema_name, sensitive));
if matched {
if job.config.table_filter_mode == "include" {
selected_tables.push(table.name.clone());
} else {
excluded_tables.push(table.name.clone());
}
}
}
let included = if job.config.table_filter_mode == "include" {
selected_tables.len()
} else {
tables.len() - excluded_tables.len()
};
included_count += included;
if included == 0 {
continue;
}
}
targets.push((schema_name.clone(), selected_tables, excluded_tables));
}
if targets.is_empty() {
continue;
}
check_cancel(stop)?;
let snapshot = database_export::begin_database_backup_snapshot_core_for_export(
state,
&job.config.connection_id,
database,
Some(&job.run.id),
)
.await?;
let result = async {
for (schema_index, (schema_name, selected_tables, excluded_tables)) in targets.iter().enumerate() {
check_cancel(stop)?;
let stem = if schemas.len() > 1 { format!("{database}.{schema_name}") } else { database.clone() };
let name = render_template(
job.config.file_name_pattern.as_deref().unwrap_or(DEFAULT_FILE),
&job.run,
&stem,
&job.time_zone,
false,
)?;
let extension = if job.config.output_compression == "gzip" { "sql.gz" } else { "sql" };
let path = output.join(format!("{name}.{extension}"));
check_output_ancestors(&path, Path::new(&job.config.destination_directory))?;
if path.try_exists().map_err(|e| e.to_string())? {
return Err(format!("Backup file already exists: {}", path.display()));
}
if job.run.files.iter().any(|f| Path::new(&f.file_path) == path) {
return Err("Backup filename template produced duplicate paths".into());
}
let file = BackupFile {
database: database.clone(),
schema: schema_name.clone(),
display_name: stem,
file_path: path.to_string_lossy().into_owned(),
owned: false,
};
job.run.files.push(file.clone());
self.store.progress(job.run.clone()).await?;
progress.send_replace(job.run.clone());
let request = DatabaseExportRequest {
export_id: job.run.id.clone(),
connection_id: job.config.connection_id.clone(),
database: database.clone(),
schema: schema_name.clone(),
file_path: file.file_path,
selected_tables: selected_tables.clone(),
excluded_tables: excluded_tables.clone(),
include_structure: job.config.include_structure,
include_data: job.config.include_data,
include_objects: job.config.include_objects,
include_create_database: false,
drop_table_if_exists: job.config.drop_table_if_exists,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: true,
output_compression: if job.config.output_compression == "gzip" {
DatabaseExportOutputCompression::Gzip
} else {
DatabaseExportOutputCompression::None
},
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1000,
split_max_mb: None,
};
let terminal_cancelled = std::sync::atomic::AtomicBool::new(false);
let created = Arc::new(std::sync::atomic::AtomicBool::new(false));
let result = database_export::track_backup_destination(
path,
created.clone(),
database_export::export_database_sql_core(state, &request, |event| {
if matches!(event.status, ExportStatus::Cancelled) {
terminal_cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
}
let mut run = job.run.clone();
if let Some(file) = run.files.last_mut() {
file.owned = created.load(std::sync::atomic::Ordering::Relaxed);
}
let fraction = if matches!(event.status, ExportStatus::Done) {
1.0
} else if event.total_objects > 0 {
event.object_index as f64 / event.total_objects as f64
} else {
0.0
};
run.progress_percent = ((db_index as f64
+ (schema_index as f64 + fraction) / targets.len() as f64)
/ databases.len() as f64
* 100.0)
.min(99.0);
progress.send_replace(run);
}),
)
.await;
if created.load(std::sync::atomic::Ordering::Relaxed) {
if let Some(file) = job.run.files.last_mut() {
file.owned = true;
}
} else {
// Preparation failures and external writers grant no file deletion authority.
job.run.files.pop();
}
result?;
if terminal_cancelled.load(std::sync::atomic::Ordering::Relaxed) {
stop.cancel();
}
check_cancel(stop)?;
job.run.progress_percent = progress.borrow().progress_percent;
}
Ok::<_, String>(())
}
.await;
let rollback = query::rollback_manual_transaction(state, &snapshot.session_id).await;
result?;
rollback?;
}
if job.config.table_filter_mode != "all" && included_count == 0 {
return Err("No tables matched the backup table scope".into());
}
Ok(())
}
}
fn check_cancel(stop: &CancellationToken) -> Result<(), String> {
if stop.is_cancelled() {
Err("Backup cancelled".into())
} else {
Ok(())
}
}
fn system_database(name: &str, postgres: bool) -> bool {
let name = name.to_ascii_lowercase();
if postgres {
matches!(name.as_str(), "template0" | "template1")
} else {
matches!(name.as_str(), "information_schema" | "mysql" | "performance_schema" | "sys")
}
}
fn check_output_ancestors(path: &Path, root: &Path) -> Result<(), String> {
let relative = path.strip_prefix(root).map_err(|_| "Backup output escaped destination")?;
let mut current = root.to_path_buf();
for component in relative.components() {
if !matches!(component, std::path::Component::Normal(_)) {
return Err("Invalid backup output path".into());
}
current.push(component);
match std::fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("Backup output contains a symbolic link".into())
}
Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.to_string()),
_ => {}
}
}
Ok(())
}
@@ -0,0 +1,24 @@
//! Durable, UI-independent database backup scheduling shared by desktop and Web.
mod engine;
mod models;
mod service;
mod store;
#[cfg(test)]
mod tests;
pub use models::{BackupConfig, BackupFile, BackupRun, BackupSchedule, Migration, RunRequest};
pub use service::{BackupCommand, BackupService};
pub use store::{BackupSnapshot, BackupStore};
/// Metadata and export command chains nest very large async futures - a single
/// frame can reach 60-150 KiB - which does not fit into tokio's default 2 MiB
/// worker stack. Every runtime that drives them (desktop, background worker and
/// Web server) has to reserve the same roomy stack, otherwise the process dies
/// with `fatal runtime error: stack overflow` instead of reporting an error.
pub const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024;
/// Builds the multi-threaded runtime used by processes that run metadata and
/// backup work, on a stack that can hold those nested futures.
pub fn worker_runtime() -> std::io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread().enable_all().thread_stack_size(WORKER_STACK_SIZE).build()
}
@@ -0,0 +1,297 @@
use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
pub const DEFAULT_DIRECTORY: &str = "dbx-backup__{schedule}__{timestamp}__{runId}";
pub const DEFAULT_FILE: &str = "dbx-backup__{schedule}__{timestamp}__{database}__{runId}";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupConfig {
pub connection_id: String,
#[serde(default)]
pub databases: Vec<String>,
#[serde(default = "all")]
pub table_filter_mode: String,
#[serde(default)]
pub table_patterns: Vec<String>,
pub destination_directory: String,
pub include_structure: bool,
pub include_data: bool,
pub include_objects: bool,
#[serde(default)]
pub drop_table_if_exists: bool,
#[serde(default = "none")]
pub output_compression: String,
#[serde(default)]
pub file_name_pattern: Option<String>,
}
fn all() -> String {
"all".into()
}
fn none() -> String {
"none".into()
}
fn utc() -> String {
"UTC".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupSchedule {
pub id: String,
pub name: String,
pub enabled: bool,
#[serde(flatten)]
pub config: BackupConfig,
pub frequency: String,
pub interval_hours: u32,
pub time_of_day: String,
pub weekday: u32,
pub retention_count: usize,
#[serde(default)]
pub run_directory_pattern: Option<String>,
#[serde(default = "utc")]
pub time_zone: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub next_run_at: String,
#[serde(default)]
pub last_run_at: Option<String>,
#[serde(default)]
pub last_run_status: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupFile {
pub database: String,
pub schema: String,
pub display_name: String,
pub file_path: String,
#[serde(default = "owned_by_legacy_run")]
pub owned: bool,
}
fn owned_by_legacy_run() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupRun {
pub id: String,
#[serde(default)]
pub schedule_id: Option<String>,
pub schedule_name: String,
#[serde(default)]
pub display_name: Option<String>,
pub connection_id: String,
pub connection_name: String,
#[serde(default)]
pub destination_directory: String,
pub trigger: String,
pub source: String,
pub status: String,
pub started_at: String,
#[serde(default)]
pub completed_at: Option<String>,
#[serde(default)]
pub files: Vec<BackupFile>,
#[serde(default)]
pub progress_percent: f64,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunRequest {
pub schedule_id: Option<String>,
pub config: Option<BackupConfig>,
pub display_name: Option<String>,
pub time_zone: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct Job {
pub run: BackupRun,
pub config: BackupConfig,
pub directory_pattern: Option<String>,
pub time_zone: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Migration {
#[serde(default)]
pub schedules: Vec<BackupSchedule>,
#[serde(default)]
pub runs: Vec<BackupRun>,
}
impl BackupConfig {
pub fn validate(&self) -> Result<(), String> {
bounded_text(&self.connection_id, 256, "Connection")?;
bounded_text(&self.destination_directory, 4096, "Backup directory")?;
if !std::path::Path::new(&self.destination_directory).is_absolute() {
return Err("Backup directory must be absolute".into());
}
if !self.include_structure && !self.include_data && !self.include_objects {
return Err("Select backup contents".into());
}
if !matches!(self.output_compression.as_str(), "none" | "gzip") {
return Err("Unsupported backup compression".into());
}
if !matches!(self.table_filter_mode.as_str(), "all" | "include" | "exclude") {
return Err("Unsupported backup table filter".into());
}
if self.databases.len() > 10_000 || self.table_patterns.len() > 1000 {
return Err("Too many backup targets or patterns".into());
}
for value in self.databases.iter().chain(self.table_patterns.iter()) {
bounded_text(value, 1024, "Backup target")?;
}
if self.table_filter_mode != "all" && self.table_patterns.is_empty() {
return Err("Select backup table patterns".into());
}
validate_template(self.file_name_pattern.as_deref().unwrap_or(DEFAULT_FILE), false)
}
}
impl BackupSchedule {
pub fn validate(&self) -> Result<(), String> {
self.config.validate()?;
bounded_text(&self.id, 256, "Schedule ID")?;
bounded_text(&self.name, 256, "Schedule name")?;
if !matches!(self.frequency.as_str(), "hourly" | "daily" | "weekly")
|| !(1..=168).contains(&self.interval_hours)
|| self.weekday > 6
|| !(1..=100).contains(&self.retention_count)
{
return Err("Invalid backup schedule".into());
}
NaiveTime::parse_from_str(&self.time_of_day, "%H:%M").map_err(|_| "Invalid backup time")?;
self.time_zone.parse::<Tz>().map_err(|_| "Invalid backup time zone")?;
validate_template(self.run_directory_pattern.as_deref().unwrap_or(DEFAULT_DIRECTORY), true)
}
pub fn next_after(&self, after: DateTime<Utc>) -> Result<DateTime<Utc>, String> {
if self.frequency == "hourly" {
return Ok(after + Duration::hours(self.interval_hours.clamp(1, 168) as i64));
}
let tz: Tz = self.time_zone.parse().map_err(|_| "Invalid backup time zone")?;
let time = NaiveTime::parse_from_str(&self.time_of_day, "%H:%M").map_err(|_| "Invalid backup time")?;
let today = after.with_timezone(&tz).date_naive();
for offset in 0..15 {
let date = today + Duration::days(offset);
if self.frequency == "weekly" && date.weekday().num_days_from_sunday() != self.weekday {
continue;
}
// A repeated wall-clock time runs only at its first occurrence.
// A nonexistent spring-forward time runs at the first valid minute after the gap.
for minute in 0..180 {
let local = date.and_time(time) + Duration::minutes(minute);
let candidate = match tz.from_local_datetime(&local) {
LocalResult::Single(value) => Some(value),
LocalResult::Ambiguous(first, _) => Some(first),
LocalResult::None => None,
};
if let Some(value) = candidate {
if value.with_timezone(&Utc) > after {
return Ok(value.with_timezone(&Utc));
}
break;
}
}
}
Err("Unable to calculate next backup time".into())
}
}
pub(crate) fn bounded_text(value: &str, max: usize, label: &str) -> Result<(), String> {
if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
Err(format!("Invalid {label}"))
} else {
Ok(())
}
}
pub(crate) fn validate_template(value: &str, directory: bool) -> Result<(), String> {
bounded_text(value, 1024, "backup name template")?;
if directory {
let drive_prefix = value.as_bytes().get(1) == Some(&b':') && value.as_bytes()[0].is_ascii_alphabetic();
if value.starts_with(['/', '\\'])
|| value.ends_with(['/', '\\'])
|| drive_prefix
|| value.split(['/', '\\']).any(|part| part == "." || part == "..")
{
return Err("Invalid backup directory template".into());
}
return Ok(());
}
if value.trim() != value
|| value.starts_with(['/', '\\'])
|| value.ends_with(['/', '\\', '.', ' '])
|| value.contains([':', '*', '?', '"', '<', '>', '|'])
|| (!directory && value.contains(['/', '\\']))
|| value.split(['/', '\\']).any(|part| part.is_empty() || part == "." || part == "..")
{
return Err("Invalid backup name template".into());
}
Ok(())
}
pub(crate) fn segment(value: &str) -> String {
let cleaned: String =
value.chars().map(|c| if c.is_control() || "\\/:*?\"<>|".contains(c) { '_' } else { c }).collect();
let cleaned = cleaned.trim().trim_end_matches(['.', ' ']);
let name = if cleaned.is_empty() { "database" } else { cleaned };
let first = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
if matches!(first.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (first.len() == 4
&& (first.starts_with("COM") || first.starts_with("LPT"))
&& matches!(first.as_bytes()[3], b'1'..=b'9'))
{
format!("{name}_")
} else {
name.into()
}
}
pub(crate) fn render_template(
template: &str,
run: &BackupRun,
database: &str,
time_zone: &str,
directory: bool,
) -> Result<String, String> {
validate_template(template, directory)?;
let tz: Tz = time_zone.parse().map_err(|_| "Invalid backup time zone")?;
let started =
DateTime::parse_from_rfc3339(&run.started_at).map_err(|_| "Invalid backup start time")?.with_timezone(&tz);
let timestamp = started.format(if directory { "%Y%m%d%H%M%S" } else { "%Y%m%d-%H%M%S" }).to_string();
let rendered = template
.replace("{schedule}", &segment(&run.schedule_name))
.replace("{date}", &started.format("%Y%m%d").to_string())
.replace("{timestamp}", &timestamp)
.replace("{runId}", &run.id.chars().take(8).collect::<String>());
if directory {
Ok(rendered.split(['/', '\\']).filter(|part| !part.is_empty()).map(segment).collect::<Vec<_>>().join("/"))
} else if template.contains("{database}") {
Ok(segment(&rendered.replace("{database}", &segment(database))))
} else {
Ok(format!("{}__{}", segment(&rendered), segment(database)))
}
}
pub(crate) fn matches_pattern(pattern: &str, table: &str, database: &str, schema: &str, sensitive: bool) -> bool {
let expression = format!("^{}$", regex::escape(pattern).replace("\\*", ".*").replace("\\?", "."));
regex::RegexBuilder::new(&expression).case_insensitive(!sensitive).build().is_ok_and(|re| {
re.is_match(table)
|| (!schema.is_empty() && re.is_match(&format!("{schema}.{table}")))
|| (!database.is_empty() && !schema.is_empty() && re.is_match(&format!("{database}.{schema}.{table}")))
})
}
@@ -0,0 +1,212 @@
use serde::Deserialize;
use serde_json::Value;
use std::{
path::{Component, Path, PathBuf},
sync::Arc,
};
use tokio_util::sync::CancellationToken;
use super::{models::*, BackupStore};
use crate::{connection::AppState, database_export::record_export_destination_identity};
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "camelCase")]
pub enum BackupCommand {
Snapshot,
Preview { schedule: BackupSchedule },
Save { schedule: BackupSchedule },
DeleteSchedule { id: String },
Migrate { migration: Migration },
Run { request: RunRequest },
Cancel { id: String },
Rename { id: String, name: String },
DeleteRuns { ids: Vec<String> },
File { id: String, index: usize },
}
#[derive(Clone)]
pub struct BackupService {
pub(crate) state: Arc<AppState>,
pub store: BackupStore,
pub(crate) root: Option<PathBuf>,
}
impl BackupService {
/// Web callers must supply a server-owned root, never one from the request.
pub fn new(state: Arc<AppState>, data_dir: &Path, root: Option<PathBuf>) -> Self {
Self { state, store: BackupStore::new(data_dir), root }
}
pub fn start(&self, stop: CancellationToken) -> tokio::task::JoinHandle<()> {
self.start_with_drain(stop, CancellationToken::new())
}
/// Draining finishes the active job before releasing leadership; stopping cancels it.
pub fn start_with_drain(&self, stop: CancellationToken, drain: CancellationToken) -> tokio::task::JoinHandle<()> {
let service = self.clone();
tokio::spawn(async move { service.serve(stop, drain).await })
}
pub async fn command(&self, command: BackupCommand) -> Result<Value, String> {
match command {
BackupCommand::File { id, index } => {
Ok(Value::String(self.file(&id, index).await?.to_string_lossy().into_owned()))
}
BackupCommand::Preview { schedule } => {
Ok(Value::String(schedule.next_after(chrono::Utc::now())?.to_rfc3339()))
}
BackupCommand::Snapshot => {
let mut value = serde_json::to_value(self.store.snapshot().await?).map_err(|e| e.to_string())?;
value["destinationRoot"] = serde_json::to_value(&self.root).map_err(|e| e.to_string())?;
Ok(value)
}
BackupCommand::Save { schedule } => {
schedule.validate()?;
self.validate_destination(&schedule.config.destination_directory)?;
let old = self.store.snapshot().await?.schedules.into_iter().find(|s| s.id == schedule.id);
if old.is_none_or(|s| s.config.destination_directory != schedule.config.destination_directory) {
record_export_destination_identity(&self.state, Path::new(&schedule.config.destination_directory))
.await?;
}
serde_json::to_value(self.store.save_schedule(schedule).await?).map_err(|e| e.to_string())
}
BackupCommand::DeleteSchedule { id } => {
self.store.delete_schedule(id).await?;
Ok(Value::Null)
}
BackupCommand::Migrate { migration } => {
if self.store.snapshot().await?.migrated {
return Ok(Value::Null);
}
// Browser-local history must not grant authority over arbitrary server files.
if self.root.is_some() && (!migration.schedules.is_empty() || !migration.runs.is_empty()) {
return Err("Desktop backups cannot be imported into a Web server automatically".into());
}
self.store.migrate(migration).await?;
Ok(Value::Null)
}
BackupCommand::Run { request } => {
if let Some(config) = &request.config {
self.validate_destination(&config.destination_directory)?;
}
serde_json::to_value(self.store.enqueue(request).await?).map_err(|e| e.to_string())
}
BackupCommand::Cancel { id } => Ok(Value::Bool(self.store.cancel(id).await?)),
BackupCommand::Rename { id, name } => {
self.store.rename(id, name).await?;
Ok(Value::Null)
}
BackupCommand::DeleteRuns { ids } => {
self.delete_runs(ids).await?;
Ok(Value::Null)
}
}
}
pub(crate) fn validate_destination(&self, path: &str) -> Result<PathBuf, String> {
let path = Path::new(path);
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err("Backup directory must be an absolute path without traversal".into());
}
let resolved = path.canonicalize().map_err(|e| format!("Backup directory is unavailable: {e}"))?;
if !resolved.is_dir() {
return Err("Backup destination is not a directory".into());
}
if let Some(root) = &self.root {
let root = root.canonicalize().map_err(|e| format!("Server backup root is unavailable: {e}"))?;
if !resolved.starts_with(root) {
return Err("Backup directory is outside the server backup root".into());
}
}
Ok(resolved)
}
pub async fn file(&self, id: &str, index: usize) -> Result<PathBuf, String> {
let snapshot = self.store.snapshot().await?;
let run =
snapshot.runs.iter().find(|r| r.id == id && r.status == "success").ok_or("Completed backup not found")?;
let file = run.files.get(index).ok_or("Backup file not found")?;
self.checked_file(run, Path::new(&file.file_path)).await
}
async fn checked_file(&self, run: &BackupRun, path: &Path) -> Result<PathBuf, String> {
let root = self.validate_destination(&run.destination_directory)?;
crate::database_export::verify_export_destination_identity(&self.state, Path::new(&run.destination_directory))
.await?;
let metadata = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err("Backup file is not a regular file".into());
}
let resolved = path.canonicalize().map_err(|e| e.to_string())?;
if resolved == root || !resolved.starts_with(root) {
return Err("Backup file escaped its destination".into());
}
let name = resolved.file_name().unwrap_or_default().to_string_lossy().to_ascii_lowercase();
if !name.ends_with(".sql") && !name.ends_with(".sql.gz") {
return Err("Not a SQL backup file".into());
}
Ok(resolved)
}
pub(crate) async fn cleanup(&self, run: &mut BackupRun) -> Result<(), String> {
let others = self.store.snapshot().await?.runs;
let mut retained = Vec::new();
let mut errors = Vec::new();
for file in &run.files {
if others.iter().any(|r| r.id != run.id && r.files.iter().any(|f| f.file_path == file.file_path)) {
continue;
}
let path = Path::new(&file.file_path);
match std::fs::symlink_metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
_ => {}
}
if !file.owned {
retained.push(file.clone());
errors.push("File ownership could not be confirmed after interruption; inspect the file before removing it manually".into());
continue;
}
match self.checked_file(run, path).await {
Ok(path) => {
if let Err(e) = tokio::fs::remove_file(path).await {
if e.kind() != std::io::ErrorKind::NotFound {
retained.push(file.clone());
errors.push(e.to_string());
}
}
}
Err(error) => {
retained.push(file.clone());
errors.push(error);
}
}
}
run.files = retained;
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
pub(crate) async fn delete_runs(&self, ids: Vec<String>) -> Result<(), String> {
if ids.len() > 1000 {
return Err("Too many backups selected".into());
}
let mut errors = Vec::new();
for mut run in self.store.mark_deleting(ids).await? {
if let Err(error) = self.cleanup(&mut run).await {
run.error = Some(format!("Backup cleanup failed: {error}"));
self.store.deletion_failed(run).await?;
errors.push(error);
continue;
}
self.store.remove_run(run.id).await?;
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
}
@@ -0,0 +1,484 @@
use std::path::{Path, PathBuf};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use super::models::*;
#[derive(Clone)]
pub struct BackupStore {
pub(crate) directory: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupSnapshot {
pub schedules: Vec<BackupSchedule>,
pub runs: Vec<BackupRun>,
pub migrated: bool,
pub heartbeat: Option<String>,
}
fn encode(value: &impl Serialize) -> Result<String, String> {
serde_json::to_string(value).map_err(|e| e.to_string())
}
fn decode<T: DeserializeOwned>(value: String) -> Result<T, String> {
serde_json::from_str(&value).map_err(|e| e.to_string())
}
fn sql_error(error: rusqlite::Error) -> String {
error.to_string()
}
fn schedules(conn: &Connection) -> Result<Vec<BackupSchedule>, String> {
let mut statement = conn.prepare("SELECT payload FROM schedules ORDER BY id").map_err(sql_error)?;
let rows = statement.query_map([], |row| row.get::<_, String>(0)).map_err(sql_error)?;
rows.map(|row| decode(row.map_err(sql_error)?)).collect()
}
fn run_rows(conn: &Connection) -> Result<Vec<BackupRun>, String> {
let mut statement =
conn.prepare("SELECT payload FROM runs ORDER BY created_at DESC, id DESC").map_err(sql_error)?;
let rows = statement.query_map([], |row| row.get::<_, String>(0)).map_err(sql_error)?;
rows.map(|row| decode(row.map_err(sql_error)?)).collect()
}
fn active(conn: &Connection, schedule_id: &str) -> Result<bool, String> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM runs WHERE schedule_id=? AND state IN ('queued','running'))",
[schedule_id],
|r| r.get(0),
)
.map_err(sql_error)
}
fn insert_job(
conn: &Connection,
config: BackupConfig,
schedule: Option<&BackupSchedule>,
name: String,
trigger: &str,
time_zone: Option<String>,
) -> Result<BackupRun, String> {
let count: usize = conn
.query_row("SELECT COUNT(*) FROM runs WHERE state IN ('queued','running')", [], |r| r.get(0))
.map_err(sql_error)?;
if count >= 100 {
return Err("Too many pending backups".into());
}
let run = BackupRun {
id: uuid::Uuid::new_v4().simple().to_string(),
schedule_id: schedule.map(|s| s.id.clone()),
schedule_name: name,
display_name: None,
connection_id: config.connection_id.clone(),
connection_name: String::new(),
destination_directory: config.destination_directory.clone(),
trigger: trigger.into(),
source: if schedule.is_some() { "scheduled" } else { "one-shot" }.into(),
status: "running".into(),
started_at: Utc::now().to_rfc3339(),
completed_at: None,
files: Vec::new(),
progress_percent: 0.0,
error: None,
};
let time_zone = schedule.map(|s| s.time_zone.clone()).or(time_zone).unwrap_or_else(|| "UTC".into());
time_zone.parse::<chrono_tz::Tz>().map_err(|_| "Invalid backup time zone")?;
let job = Job {
run: run.clone(),
config,
directory_pattern: schedule
.map(|s| s.run_directory_pattern.clone().unwrap_or_else(|| DEFAULT_DIRECTORY.into())),
time_zone,
};
conn.execute(
"INSERT INTO runs(id,schedule_id,state,payload,job,created_at) VALUES(?,?,'queued',?,?,?)",
params![run.id, run.schedule_id, encode(&run)?, encode(&job)?, run.started_at],
)
.map_err(sql_error)?;
Ok(run)
}
impl BackupStore {
pub fn new(data_dir: &Path) -> Self {
Self { directory: data_dir.join("database-backups") }
}
async fn access<T: Send + 'static>(
&self,
action: impl FnOnce(&mut Connection) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
let dir = self.directory.clone();
tokio::task::spawn_blocking(move || {
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut conn = Connection::open(dir.join("state.db")).map_err(sql_error)?;
conn.busy_timeout(std::time::Duration::from_secs(5)).map_err(sql_error)?;
conn.execute_batch("PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS schedules(id TEXT PRIMARY KEY,payload TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,schedule_id TEXT,state TEXT NOT NULL,payload TEXT NOT NULL,job TEXT,created_at TEXT NOT NULL,cancel INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS runs_pending ON runs(state,created_at);
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);").map_err(sql_error)?;
action(&mut conn)
}).await.map_err(|e| e.to_string())?
}
pub async fn snapshot(&self) -> Result<BackupSnapshot, String> {
self.access(|conn| {
let tx = conn.transaction().map_err(sql_error)?;
Ok(BackupSnapshot {
schedules: schedules(&tx)?,
runs: run_rows(&tx)?,
migrated: tx
.query_row("SELECT EXISTS(SELECT 1 FROM metadata WHERE key='migrated')", [], |r| r.get(0))
.map_err(sql_error)?,
heartbeat: tx
.query_row("SELECT value FROM metadata WHERE key='heartbeat'", [], |r| r.get(0))
.optional()
.map_err(sql_error)?,
})
})
.await
}
pub async fn save_schedule(&self, mut value: BackupSchedule) -> Result<BackupSchedule, String> {
value.validate()?;
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
if active(&tx, &value.id)? {
return Err("Cannot edit a running backup schedule".into());
}
let old: Option<String> = tx
.query_row("SELECT payload FROM schedules WHERE id=?", [&value.id], |r| r.get(0))
.optional()
.map_err(sql_error)?;
let now = Utc::now();
let mut timing_changed = true;
if let Some(old) = old {
let old: BackupSchedule = decode(old)?;
if old.updated_at != value.updated_at {
return Err("Backup schedule changed; reload before saving".into());
}
timing_changed = old.frequency != value.frequency
|| old.interval_hours != value.interval_hours
|| old.time_of_day != value.time_of_day
|| old.weekday != value.weekday
|| old.time_zone != value.time_zone
|| (!old.enabled && value.enabled);
value.created_at = old.created_at;
value.last_run_at = old.last_run_at;
value.last_run_status = old.last_run_status;
value.next_run_at = old.next_run_at;
} else {
value.created_at = now.to_rfc3339();
value.last_run_at = None;
value.last_run_status = None;
}
if timing_changed {
value.next_run_at = value.next_after(now)?.to_rfc3339();
}
value.updated_at = now.to_rfc3339();
tx.execute(
"INSERT INTO schedules(id,payload) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
params![value.id, encode(&value)?],
)
.map_err(sql_error)?;
tx.commit().map_err(sql_error)?;
Ok(value)
})
.await
}
pub async fn delete_schedule(&self, id: String) -> Result<(), String> {
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
if active(&tx, &id)? {
return Err("Cannot delete a running backup schedule".into());
}
tx.execute("DELETE FROM schedules WHERE id=?", [id]).map_err(sql_error)?;
tx.commit().map_err(sql_error)
})
.await
}
pub async fn migrate(&self, migration: Migration) -> Result<(), String> {
if migration.schedules.len() > 1000 || migration.runs.len() > 10_000 {
return Err("Backup migration is too large".into());
}
for schedule in &migration.schedules {
schedule.validate()?;
}
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let done: bool = tx
.query_row("SELECT EXISTS(SELECT 1 FROM metadata WHERE key='migrated')", [], |r| r.get(0))
.map_err(sql_error)?;
if done {
return Ok(());
}
for mut schedule in migration.schedules {
if chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at).is_err() {
schedule.next_run_at = schedule.next_after(Utc::now())?.to_rfc3339();
}
tx.execute(
"INSERT OR IGNORE INTO schedules(id,payload) VALUES(?,?)",
params![schedule.id, encode(&schedule)?],
)
.map_err(sql_error)?;
}
for mut run in migration.runs {
if run.status == "running" {
run.status = "failed".into();
run.error = Some("Backup interrupted before migration".into());
run.completed_at = Some(Utc::now().to_rfc3339());
}
if !matches!(run.status.as_str(), "success" | "failed" | "cancelled") {
return Err("Invalid migrated backup status".into());
}
tx.execute(
"INSERT OR IGNORE INTO runs(id,schedule_id,state,payload,created_at) VALUES(?,?,?,?,?)",
params![run.id, run.schedule_id, run.status, encode(&run)?, run.started_at],
)
.map_err(sql_error)?;
}
tx.execute("INSERT INTO metadata(key,value) VALUES('migrated','1')", []).map_err(sql_error)?;
tx.commit().map_err(sql_error)
})
.await
}
pub async fn enqueue(&self, request: RunRequest) -> Result<BackupRun, String> {
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let run = if let Some(id) = request.schedule_id {
if active(&tx, &id)? {
return Err("Backup schedule is already running".into());
}
let value: String =
tx.query_row("SELECT payload FROM schedules WHERE id=?", [&id], |r| r.get(0)).map_err(sql_error)?;
let schedule: BackupSchedule = decode(value)?;
insert_job(&tx, schedule.config.clone(), Some(&schedule), schedule.name.clone(), "manual", None)?
} else {
let config = request.config.ok_or("Backup configuration is required")?;
config.validate()?;
let name = request.display_name.unwrap_or_else(|| "Database backup".into());
bounded_text(&name, 256, "Backup name")?;
insert_job(&tx, config, None, name, "manual", request.time_zone)?
};
tx.commit().map_err(sql_error)?;
Ok(run)
})
.await
}
pub(crate) async fn enqueue_due(&self) -> Result<(), String> {
self.access(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let now = Utc::now();
for mut schedule in schedules(&tx)? {
if !schedule.enabled {
continue;
}
let pending: usize = tx
.query_row("SELECT COUNT(*) FROM runs WHERE state IN ('queued','running')", [], |r| r.get(0))
.map_err(sql_error)?;
if pending >= 100 {
break;
}
let due = chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at)
.map_err(|_| "Invalid persisted backup time")?
.with_timezone(&Utc)
<= now;
if !schedule.enabled || !due || active(&tx, &schedule.id)? {
continue;
}
insert_job(&tx, schedule.config.clone(), Some(&schedule), schedule.name.clone(), "scheduled", None)?;
// Persist advancement together with the queue entry; a crash cannot replay every missed interval.
schedule.next_run_at = schedule.next_after(now)?.to_rfc3339();
tx.execute("UPDATE schedules SET payload=? WHERE id=?", params![encode(&schedule)?, schedule.id])
.map_err(sql_error)?;
}
tx.commit().map_err(sql_error)
})
.await
}
pub(crate) async fn claim(&self) -> Result<Option<Job>, String> {
self.access(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let payload: Option<String> = tx
.query_row("SELECT job FROM runs WHERE state='queued' ORDER BY created_at,id LIMIT 1", [], |r| r.get(0))
.optional()
.map_err(sql_error)?;
let job: Option<Job> = payload.map(decode).transpose()?;
if let Some(job) = &job {
tx.execute("UPDATE runs SET state='running' WHERE id=?", [&job.run.id]).map_err(sql_error)?;
}
tx.commit().map_err(sql_error)?;
Ok(job)
})
.await
}
pub async fn cancel(&self, id: String) -> Result<bool, String> {
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let queued: Option<String> = tx
.query_row("SELECT payload FROM runs WHERE id=? AND state='queued'", [&id], |r| r.get(0))
.optional()
.map_err(sql_error)?;
let accepted = if let Some(payload) = queued {
let mut run: BackupRun = decode(payload)?;
run.status = "cancelled".into();
run.completed_at = Some(Utc::now().to_rfc3339());
tx.execute(
"UPDATE runs SET state='cancelled',cancel=1,job=NULL,payload=? WHERE id=?",
params![encode(&run)?, id],
)
.map_err(sql_error)?
> 0
} else {
tx.execute("UPDATE runs SET cancel=1 WHERE id=? AND state='running'", [&id]).map_err(sql_error)? > 0
};
tx.commit().map_err(sql_error)?;
Ok(accepted)
})
.await
}
pub(crate) async fn cancelled(&self, id: String) -> Result<bool, String> {
self.access(move |conn| {
conn.query_row("SELECT cancel FROM runs WHERE id=?", [id], |r| r.get(0)).map_err(sql_error)
})
.await
}
pub(crate) async fn progress(&self, run: BackupRun) -> Result<(), String> {
self.access(move |conn| {
conn.execute("UPDATE runs SET payload=? WHERE id=? AND state='running'", params![encode(&run)?, run.id])
.map_err(sql_error)?;
Ok(())
})
.await
}
pub(crate) async fn finish(&self, run: BackupRun) -> Result<(), String> {
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
tx.execute(
"UPDATE runs SET state=?,payload=?,job=NULL WHERE id=?",
params![run.status, encode(&run)?, run.id],
)
.map_err(sql_error)?;
if let Some(id) = &run.schedule_id {
let payload: Option<String> = tx
.query_row("SELECT payload FROM schedules WHERE id=?", [id], |r| r.get(0))
.optional()
.map_err(sql_error)?;
if let Some(payload) = payload {
let mut schedule: BackupSchedule = decode(payload)?;
schedule.last_run_at = run.completed_at.clone();
schedule.last_run_status = Some(run.status.clone());
let now = Utc::now();
if chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at)
.is_ok_and(|t| t.with_timezone(&Utc) <= now)
{
schedule.next_run_at = schedule.next_after(now)?.to_rfc3339();
}
tx.execute("UPDATE schedules SET payload=? WHERE id=?", params![encode(&schedule)?, id])
.map_err(sql_error)?;
}
}
tx.commit().map_err(sql_error)
})
.await
}
pub async fn rename(&self, id: String, name: String) -> Result<(), String> {
bounded_text(&name, 256, "Backup name")?;
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let payload: String = tx
.query_row(
"SELECT payload FROM runs WHERE id=? AND state IN ('success','failed','cancelled')",
[&id],
|r| r.get(0),
)
.map_err(sql_error)?;
let mut run: BackupRun = decode(payload)?;
run.display_name = Some(name);
tx.execute("UPDATE runs SET payload=? WHERE id=?", params![encode(&run)?, id]).map_err(sql_error)?;
tx.commit().map_err(sql_error)
})
.await
}
pub(crate) async fn mark_deleting(&self, ids: Vec<String>) -> Result<Vec<BackupRun>, String> {
self.access(move |conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
let mut runs = Vec::new();
for id in ids {
let payload: String = tx
.query_row(
"SELECT payload FROM runs WHERE id=? AND state IN ('success','failed','cancelled','deleting')",
[&id],
|r| r.get(0),
)
.map_err(sql_error)?;
runs.push(decode(payload)?);
tx.execute("UPDATE runs SET state='deleting' WHERE id=?", [id]).map_err(sql_error)?;
}
tx.commit().map_err(sql_error)?;
Ok(runs)
})
.await
}
pub(crate) async fn remove_run(&self, id: String) -> Result<(), String> {
self.access(move |conn| {
conn.execute("DELETE FROM runs WHERE id=? AND state='deleting'", [id]).map_err(sql_error)?;
Ok(())
})
.await
}
pub(crate) async fn deletion_failed(&self, run: BackupRun) -> Result<(), String> {
self.access(move |conn| {
conn.execute(
"UPDATE runs SET state=?,payload=? WHERE id=? AND state='deleting'",
params![run.status, encode(&run)?, run.id],
)
.map_err(sql_error)?;
Ok(())
})
.await
}
pub(crate) async fn heartbeat(&self) -> Result<(), String> {
self.access(|conn| { conn.execute("INSERT INTO metadata(key,value) VALUES('heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [Utc::now().to_rfc3339()]).map_err(sql_error)?; Ok(()) }).await
}
pub(crate) async fn recover(&self) -> Result<(), String> {
self.access(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
for mut run in run_rows(&tx)? {
let state: String =
tx.query_row("SELECT state FROM runs WHERE id=?", [&run.id], |r| r.get(0)).map_err(sql_error)?;
if state != "running" && state != "deleting" {
continue;
}
run.status = "failed".into();
run.error =
Some("Backup interrupted: the previous worker stopped. Retained files may be incomplete.".into());
run.completed_at = Some(Utc::now().to_rfc3339());
tx.execute(
"UPDATE runs SET state='failed',payload=?,job=NULL WHERE id=?",
params![encode(&run)?, run.id],
)
.map_err(sql_error)?;
}
tx.commit().map_err(sql_error)
})
.await
}
}
@@ -0,0 +1,642 @@
use super::*;
use chrono::{DateTime, Utc};
use serde_json::json;
use std::{sync::Arc, time::Duration};
use tokio_util::sync::CancellationToken;
fn schedule(dir: &std::path::Path) -> BackupSchedule {
serde_json::from_value(json!({
"id":"daily", "name":"Daily backup", "enabled":true, "connectionId":"mysql", "databases":[],
"destinationDirectory":dir.to_string_lossy(), "includeStructure":true, "includeData":true, "includeObjects":true,
"frequency":"daily", "intervalHours":1, "timeOfDay":"02:30", "weekday":0, "retentionCount":2,
"timeZone":"America/New_York", "createdAt":"2026-01-01T00:00:00Z", "updatedAt":"2026-01-01T00:00:00Z",
"nextRunAt":"2026-01-01T00:00:00Z"
})).unwrap()
}
fn utc(value: &str) -> DateTime<Utc> {
value.parse().unwrap()
}
fn request(config: BackupConfig) -> RunRequest {
RunRequest { schedule_id: None, config: Some(config), display_name: None, time_zone: None }
}
#[test]
fn wall_clock_schedule_handles_dst_and_does_not_repeat_ambiguous_time() {
let dir = tempfile::tempdir().unwrap();
let mut schedule = schedule(dir.path());
assert_eq!(schedule.next_after(utc("2026-03-08T06:00:00Z")).unwrap(), utc("2026-03-08T07:00:00Z"));
schedule.time_of_day = "01:30".into();
assert_eq!(schedule.next_after(utc("2026-11-01T04:00:00Z")).unwrap(), utc("2026-11-01T05:30:00Z"));
assert_eq!(schedule.next_after(utc("2026-11-01T05:31:00Z")).unwrap(), utc("2026-11-02T06:30:00Z"));
}
#[test]
fn hourly_uses_elapsed_time_and_weekly_uses_saved_zone() {
let dir = tempfile::tempdir().unwrap();
let mut schedule = schedule(dir.path());
schedule.frequency = "hourly".into();
assert_eq!(schedule.next_after(utc("2026-03-08T06:30:00Z")).unwrap(), utc("2026-03-08T07:30:00Z"));
schedule.frequency = "weekly".into();
schedule.time_zone = "Asia/Shanghai".into();
assert_eq!(schedule.next_after(utc("2026-09-12T10:00:00Z")).unwrap(), utc("2026-09-12T18:30:00Z"));
}
#[test]
fn invalid_schedule_and_path_templates_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let mut schedule = schedule(dir.path());
schedule.time_zone = "invalid/timezone".into();
assert!(schedule.validate().is_err());
for path in ["../escape", "/absolute", "C:\\escape", "safe/../escape", "a/", "a\\..\\b"] {
assert!(super::models::validate_template(path, true).is_err(), "{path}");
}
assert!(super::models::validate_template("{date}/{schedule}/{runId}", true).is_ok());
assert!(super::models::matches_pattern("app.public.user?", "users", "app", "public", true));
assert!(!super::models::matches_pattern("PUBLIC.*", "users", "app", "public", true));
assert!(super::models::matches_pattern("PUBLIC.*", "users", "app", "public", false));
}
#[tokio::test]
async fn schedule_survives_reopen_and_rejects_stale_edit() {
let dir = tempfile::tempdir().unwrap();
let store = BackupStore::new(dir.path());
let first = store.save_schedule(schedule(dir.path())).await.unwrap();
let mut next = first.clone();
next.name = "Renamed".into();
store.save_schedule(next).await.unwrap();
assert!(store.save_schedule(first).await.unwrap_err().contains("changed"));
let reopened = BackupStore::new(dir.path()).snapshot().await.unwrap();
assert_eq!(reopened.schedules[0].name, "Renamed");
assert!(DateTime::parse_from_rfc3339(&reopened.schedules[0].next_run_at).unwrap().with_timezone(&Utc) > Utc::now());
}
#[tokio::test]
async fn migration_is_atomic_idempotent_and_keeps_only_one_catchup_job() {
let dir = tempfile::tempdir().unwrap();
let store = BackupStore::new(dir.path());
let mut invalid = schedule(dir.path());
invalid.time_zone = "bad".into();
assert!(store.migrate(Migration { schedules: vec![schedule(dir.path()), invalid], runs: vec![] }).await.is_err());
assert!(!store.snapshot().await.unwrap().migrated);
store.migrate(Migration { schedules: vec![schedule(dir.path())], runs: vec![] }).await.unwrap();
store.migrate(Migration::default()).await.unwrap();
let (a, b) = tokio::join!(store.enqueue_due(), store.enqueue_due());
a.unwrap();
b.unwrap();
let snapshot = store.snapshot().await.unwrap();
assert!(snapshot.migrated);
assert_eq!(snapshot.schedules.len(), 1);
assert_eq!(snapshot.runs.len(), 1);
assert_eq!(snapshot.runs[0].trigger, "scheduled");
assert!(utc(&snapshot.schedules[0].next_run_at) > Utc::now());
assert!(store.delete_schedule("daily".into()).await.is_err());
}
#[tokio::test]
async fn concurrent_manual_requests_do_not_duplicate_a_schedule() {
let dir = tempfile::tempdir().unwrap();
let store = BackupStore::new(dir.path());
store.save_schedule(schedule(dir.path())).await.unwrap();
let req = RunRequest { schedule_id: Some("daily".into()), config: None, display_name: None, time_zone: None };
let (left, right) = tokio::join!(store.enqueue(req.clone()), store.enqueue(req));
assert_ne!(left.is_ok(), right.is_ok());
assert_eq!(store.snapshot().await.unwrap().runs.len(), 1);
}
#[tokio::test]
async fn recovery_preserves_queued_jobs_and_marks_orphaned_running_jobs_failed() {
let dir = tempfile::tempdir().unwrap();
let store = BackupStore::new(dir.path());
let run = store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
store.recover().await.unwrap();
assert_eq!(store.claim().await.unwrap().unwrap().run.id, run.id);
store.recover().await.unwrap();
assert!(store.claim().await.unwrap().is_none());
let recovered = &store.snapshot().await.unwrap().runs[0];
assert_eq!(recovered.status, "failed");
assert!(recovered.error.as_ref().unwrap().contains("interrupted"));
}
async fn service(dir: &std::path::Path, root: Option<std::path::PathBuf>) -> BackupService {
let storage = crate::storage::Storage::open(&dir.join("dbx.db")).await.unwrap();
BackupService::new(Arc::new(crate::connection::AppState::new(storage)), dir, root)
}
#[tokio::test]
async fn web_rejects_external_directories_and_untrusted_history_import() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("backups");
std::fs::create_dir(&root).unwrap();
let service = service(dir.path(), Some(root.clone())).await;
assert!(service.command(BackupCommand::Save { schedule: schedule(dir.path()) }).await.is_err());
assert!(service
.command(BackupCommand::Migrate { migration: Migration { schedules: vec![schedule(&root)], runs: vec![] } })
.await
.is_err());
service.command(BackupCommand::Save { schedule: schedule(&root) }).await.unwrap();
let snapshot = service.store.snapshot().await.unwrap();
assert_eq!(snapshot.schedules.len(), 1);
}
#[tokio::test]
async fn deletion_only_removes_recorded_files_within_the_backup_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("backups");
std::fs::create_dir(&root).unwrap();
let service = service(dir.path(), Some(root.clone())).await;
let inside = root.join("test.sql");
std::fs::write(&inside, "SELECT 1;").unwrap();
let outside = dir.path().join("outside.sql");
std::fs::write(&outside, "keep").unwrap();
let mut run = service.store.enqueue(request(schedule(&root).config)).await.unwrap();
service.store.claim().await.unwrap();
run.status = "success".into();
run.files = vec![BackupFile {
database: "app".into(),
schema: "app".into(),
display_name: "test".into(),
file_path: outside.to_string_lossy().into_owned(),
owned: true,
}];
service.store.finish(run.clone()).await.unwrap();
assert!(service.file(&run.id, 0).await.is_err());
assert!(service.delete_runs(vec![run.id.clone()]).await.is_err());
assert!(outside.exists());
assert!(inside.exists());
run.files[0].file_path = inside.to_string_lossy().into_owned();
run.files[0].owned = false;
service.store.finish(run.clone()).await.unwrap();
assert!(service.delete_runs(vec![run.id.clone()]).await.is_err());
assert!(inside.exists());
run.files[0].owned = true;
service.store.finish(run.clone()).await.unwrap();
assert_eq!(service.file(&run.id, 0).await.unwrap(), inside.canonicalize().unwrap());
assert!(service.file(&run.id, 1).await.is_err());
assert!(service.file("unknown", 0).await.is_err());
service.delete_runs(vec![run.id]).await.unwrap();
assert!(!inside.exists());
assert!(outside.exists());
assert!(service.store.snapshot().await.unwrap().runs.is_empty());
}
#[tokio::test]
async fn draining_transfers_leadership_without_cancelling_and_cancel_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let service = service(dir.path(), None).await;
let run = service.store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
assert!(service.store.cancel(run.id.clone()).await.unwrap());
let first_stop = CancellationToken::new();
let first_drain = CancellationToken::new();
let second_stop = CancellationToken::new();
let first = service.start_with_drain(first_stop.clone(), first_drain.clone());
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let snapshot = service.store.snapshot().await.unwrap();
if snapshot.heartbeat.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap();
let probe =
std::fs::OpenOptions::new().read(true).write(true).open(service.store.directory.join("worker.lock")).unwrap();
assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err());
let second = service.start(second_stop.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
first_drain.cancel();
tokio::time::timeout(Duration::from_secs(5), first).await.unwrap().unwrap();
assert!(!first_stop.is_cancelled());
let previous = service.store.snapshot().await.unwrap().heartbeat;
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if service.store.snapshot().await.unwrap().heartbeat != previous {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap();
assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err());
second_stop.cancel();
second.await.unwrap();
fs2::FileExt::try_lock_exclusive(&probe).unwrap();
fs2::FileExt::unlock(&probe).unwrap();
let snapshot = BackupStore::new(dir.path()).snapshot().await.unwrap();
assert_eq!(snapshot.runs.len(), 1);
assert_eq!(snapshot.runs[0].status, "cancelled");
}
#[tokio::test]
async fn worker_waits_for_security_migration_and_resumes_without_restarting() {
let directory = tempfile::tempdir().unwrap();
let legacy_path = directory.path().join("connections.json");
std::fs::write(&legacy_path, "[]").unwrap();
let storage = crate::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap();
let service = BackupService::new(Arc::new(crate::connection::AppState::new(storage)), directory.path(), None);
let queued = service.store.enqueue(request(schedule(directory.path()).config)).await.unwrap();
assert!(!service.state.storage.inspect_data_migration().await.unwrap().is_ready());
let stop = CancellationToken::new();
let worker = service.start(stop.clone());
tokio::time::sleep(Duration::from_millis(2200)).await;
let waiting = service.store.snapshot().await.unwrap();
assert!(waiting.heartbeat.is_none());
assert_eq!(waiting.runs[0].id, queued.id);
assert_eq!(waiting.runs[0].status, queued.status);
assert_eq!(std::fs::read_to_string(&legacy_path).unwrap(), "[]");
assert!(!directory.path().join("connections.json.bak").exists());
service.state.storage.start_data_migration().await.unwrap();
let resumed = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let snapshot = service.store.snapshot().await.unwrap();
if snapshot.heartbeat.is_some() && snapshot.runs[0].status == "failed" {
break snapshot;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await;
stop.cancel();
tokio::time::timeout(Duration::from_secs(5), worker).await.unwrap().unwrap();
let resumed = resumed.unwrap();
assert_eq!(resumed.runs[0].id, queued.id);
assert!(resumed.runs[0].error.is_some());
assert!(!legacy_path.exists());
assert!(directory.path().join("connections.json.bak").exists());
}
#[tokio::test]
async fn stopped_or_drained_startup_does_not_consume_existing_jobs() {
let dir = tempfile::tempdir().unwrap();
let service = service(dir.path(), None).await;
let queued = service.store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
for draining in [false, true] {
let stop = CancellationToken::new();
let drain = CancellationToken::new();
if draining {
drain.cancel();
} else {
stop.cancel();
}
tokio::time::timeout(Duration::from_secs(5), service.start_with_drain(stop, drain)).await.unwrap().unwrap();
let snapshot = service.store.snapshot().await.unwrap();
assert!(snapshot.heartbeat.is_none());
assert_eq!(snapshot.runs.len(), 1);
assert_eq!(snapshot.runs[0].id, queued.id);
assert_eq!(snapshot.runs[0].status, queued.status);
}
assert_eq!(service.store.claim().await.unwrap().unwrap().run.id, queued.id);
}
#[tokio::test]
async fn upgrade_reopen_recovers_interrupted_work_without_losing_queued_jobs_or_disabled_schedules() {
let dir = tempfile::tempdir().unwrap();
let store = BackupStore::new(dir.path());
let mut disabled = schedule(dir.path());
disabled.enabled = false;
store.save_schedule(disabled).await.unwrap();
let interrupted = store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
assert_eq!(store.claim().await.unwrap().unwrap().run.id, interrupted.id);
let queued = store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
let cancelled = store.enqueue(request(schedule(dir.path()).config)).await.unwrap();
store.cancel(cancelled.id.clone()).await.unwrap();
drop(store);
let reopened = BackupStore::new(dir.path());
reopened.recover().await.unwrap();
reopened.recover().await.unwrap();
let snapshot = reopened.snapshot().await.unwrap();
assert!(!snapshot.schedules[0].enabled);
assert_eq!(snapshot.runs.len(), 3);
assert_eq!(snapshot.runs.iter().find(|run| run.id == interrupted.id).unwrap().status, "failed");
assert_eq!(snapshot.runs.iter().find(|run| run.id == cancelled.id).unwrap().status, "cancelled");
assert_eq!(reopened.claim().await.unwrap().unwrap().run.id, queued.id);
assert!(reopened.claim().await.unwrap().is_none());
}
#[test]
#[ignore = "requires a disposable MySQL endpoint configured by DBX_LIVE_SQL_FILE_MYSQL_* variables"]
fn live_mysql_worker_exports_saved_connection_and_applies_retention() {
live_backup_runtime().block_on(live_mysql_worker_scenario());
}
async fn live_mysql_worker_scenario() {
use crate::{models::connection::ConnectionConfig, query::execute_sql_statement};
use futures::FutureExt;
use std::io::Read;
let dir = tempfile::tempdir().unwrap();
let service = service(dir.path(), None).await;
let config: ConnectionConfig = serde_json::from_value(json!({
"id":"mysql", "name":"Worker test", "db_type":"mysql", "save_password":true,
"host":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_HOST").unwrap(),
"port":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_PORT").ok().and_then(|p| p.parse::<u16>().ok()).unwrap_or(3306),
"username":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_USER").unwrap(),
"password":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_PASSWORD").unwrap(),
"database":null, "connect_timeout_secs":10, "query_timeout_secs":30
}))
.unwrap();
service.state.storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
service.state.configs.write().await.insert(config.id.clone(), config);
let database = format!("dbx_worker_{}", uuid::Uuid::new_v4().simple());
execute_sql_statement(&service.state, "mysql", "", &format!("CREATE DATABASE `{database}`"), None, None)
.await
.unwrap();
let stop = CancellationToken::new();
let worker = service.start(stop.clone());
let outcome = std::panic::AssertUnwindSafe(async {
execute_sql_statement(
&service.state,
"mysql",
&database,
"CREATE TABLE chosen (id INT PRIMARY KEY, value TEXT)",
None,
None,
)
.await?;
execute_sql_statement(
&service.state,
"mysql",
&database,
"INSERT INTO chosen VALUES (101, 'background-worker-sentinel')",
None,
None,
)
.await?;
execute_sql_statement(&service.state, "mysql", &database, "CREATE TABLE skipped (id INT)", None, None).await?;
let mut plan = schedule(dir.path());
plan.enabled = false;
plan.retention_count = 1;
plan.config.databases = vec![database.clone()];
plan.config.output_compression = "gzip".into();
plan.config.table_filter_mode = "include".into();
plan.config.table_patterns = vec!["chosen".into()];
service.command(BackupCommand::Save { schedule: plan }).await?;
let mut previous = None;
for _ in 0..2 {
let run = service
.store
.enqueue(RunRequest {
schedule_id: Some("daily".into()),
config: None,
display_name: None,
time_zone: None,
})
.await?;
let completed = tokio::time::timeout(Duration::from_secs(60), async {
loop {
let snapshot = service.store.snapshot().await.unwrap();
let current = snapshot.runs.iter().find(|r| r.id == run.id).unwrap();
if current.status != "running" {
break current.clone();
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| "Backup timed out")?;
assert_eq!(completed.status, "success", "{:?}", completed.error);
assert_eq!(completed.files.len(), 1);
let path = std::path::PathBuf::from(&completed.files[0].file_path);
let mut sql = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(&path).unwrap()).read_to_string(&mut sql).unwrap();
assert!(sql.contains("background-worker-sentinel"));
assert!(!sql.contains("CREATE TABLE `skipped`"));
if let Some(previous) = previous {
tokio::time::timeout(Duration::from_secs(5), async {
while std::path::Path::new(&previous).exists() {
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.map_err(|_| "Retention did not remove the old file")?;
}
previous = Some(path);
}
assert_eq!(service.store.snapshot().await?.runs.len(), 1);
let mut empty_scope = schedule(dir.path()).config;
empty_scope.databases = vec![database.clone()];
empty_scope.table_filter_mode = "include".into();
empty_scope.table_patterns = vec!["missing_table".into()];
let run = service.store.enqueue(request(empty_scope)).await?;
let completed = tokio::time::timeout(Duration::from_secs(60), async {
loop {
let snapshot = service.store.snapshot().await.unwrap();
let current = snapshot.runs.iter().find(|r| r.id == run.id).unwrap();
if current.status != "running" {
break current.clone();
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.map_err(|_| "Empty-scope backup timed out")?;
assert_eq!(completed.status, "failed");
assert!(completed.error.as_deref().unwrap().contains("No tables matched"));
assert!(completed.files.is_empty());
assert!(previous.unwrap().exists());
Ok::<_, String>(())
})
.catch_unwind()
.await;
stop.cancel();
worker.await.unwrap();
let cleanup =
execute_sql_statement(&service.state, "mysql", "", &format!("DROP DATABASE `{database}`"), None, None).await;
service.state.shutdown(Duration::from_secs(3)).await;
cleanup.unwrap();
outcome.unwrap().unwrap();
}
/// Runs a live scenario on a runtime configured like the processes that drive
/// backups. The export path nests very large async futures, so it needs the same
/// roomy worker stack the desktop runtime sets; on tokio's default stack the
/// process aborts with `fatal runtime error: stack overflow` instead of
/// reporting a failed assertion.
fn live_backup_runtime() -> tokio::runtime::Runtime {
super::worker_runtime().unwrap()
}
fn live_postgres_config(id: &str, database: &str) -> crate::models::connection::ConnectionConfig {
serde_json::from_value(json!({
"id": id, "name": id, "db_type": "postgres", "save_password": true,
"host": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_HOST").unwrap(),
"port": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_PORT").ok().and_then(|p| p.parse::<u16>().ok()).unwrap_or(5432),
"username": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_USER").unwrap(),
"password": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_PASSWORD").unwrap(),
"database": database, "connect_timeout_secs": 10, "query_timeout_secs": 30
}))
.unwrap()
}
async fn await_run(service: &BackupService, run_id: &str) -> BackupRun {
tokio::time::timeout(Duration::from_secs(60), async {
loop {
let snapshot = service.store.snapshot().await.unwrap();
let current = snapshot.runs.iter().find(|r| r.id == run_id).unwrap().clone();
if current.status != "running" && current.status != "queued" {
break current;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.expect("backup run did not finish within 60s")
}
async fn enqueue_and_run(service: &BackupService, schedule_id: &str) -> BackupRun {
let run = service
.store
.enqueue(RunRequest {
schedule_id: Some(schedule_id.into()),
config: None,
display_name: None,
time_zone: None,
})
.await
.unwrap();
await_run(service, &run.id).await
}
fn plan_with_id(dir: &std::path::Path, id: &str) -> BackupSchedule {
let mut plan = schedule(dir);
plan.id = id.to_string();
plan.enabled = false;
plan
}
fn read_backup(path: &str, gzip: bool) -> String {
let file = std::fs::File::open(path).unwrap();
if !gzip {
return std::fs::read_to_string(path).unwrap();
}
use std::io::Read;
let mut sql = String::new();
flate2::read::GzDecoder::new(file).read_to_string(&mut sql).unwrap();
sql
}
#[test]
#[ignore = "requires a disposable PostgreSQL endpoint configured by DBX_LIVE_SQL_FILE_POSTGRES_* variables"]
fn live_postgres_worker_exports_selected_tables_across_schemas() {
use crate::query::execute_sql_statement;
live_backup_runtime().block_on(async {
let dir = tempfile::tempdir().unwrap();
let service = service(dir.path(), None).await;
let stop = CancellationToken::new();
let worker = service.start(stop.clone());
let admin_id = "postgres-admin";
let admin = live_postgres_config(admin_id, "postgres");
service.state.storage.save_connections(std::slice::from_ref(&admin)).await.unwrap();
service.state.configs.write().await.insert(admin.id.clone(), admin);
let database = format!("dbx_pg_backup_{}", uuid::Uuid::new_v4().simple());
execute_sql_statement(
&service.state,
admin_id,
"postgres",
&format!("CREATE DATABASE \"{database}\""),
None,
None,
)
.await
.unwrap();
let config = live_postgres_config("postgres", &database);
service.state.storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
service.state.configs.write().await.insert(config.id.clone(), config);
for statement in [
"CREATE SCHEMA app",
"CREATE SCHEMA audit",
"CREATE TABLE public.accounts (id integer PRIMARY KEY, name text NOT NULL)",
"CREATE TABLE app.orders (id integer PRIMARY KEY, total numeric(12,2) NOT NULL)",
"CREATE TABLE app.\"MixedCase\" (id integer PRIMARY KEY)",
"CREATE VIEW app.big_orders AS SELECT id, total FROM app.orders WHERE total > 100",
"CREATE TABLE audit.events (id integer PRIMARY KEY, note text)",
"INSERT INTO public.accounts VALUES (1, 'Alice')",
"INSERT INTO app.orders VALUES (7, 42.50), (8, 420.00)",
"INSERT INTO app.\"MixedCase\" VALUES (1)",
"INSERT INTO audit.events VALUES (9, 'sentinel-event')",
] {
execute_sql_statement(&service.state, "postgres", &database, statement, None, None).await.unwrap();
}
// Table scope: only `app.orders` (schema-qualified pattern) and the
// case-sensitive `MixedCase` name, which pg resolves case-sensitively.
let mut plan = plan_with_id(dir.path(), "scope");
plan.retention_count = 2;
plan.config.connection_id = "postgres".into();
plan.config.databases = vec![database.clone()];
plan.config.output_compression = "gzip".into();
plan.config.table_filter_mode = "include".into();
plan.config.table_patterns = vec!["app.orders".into(), "MixedCase".into()];
service.command(BackupCommand::Save { schedule: plan }).await.unwrap();
let completed = enqueue_and_run(&service, "scope").await;
assert_eq!(completed.status, "success", "{:?}", completed.error);
assert_eq!(
completed.files.len(),
1,
"{:?}",
completed.files.iter().map(|f| &f.display_name).collect::<Vec<_>>()
);
let sql = read_backup(&completed.files[0].file_path, true);
assert!(sql.contains("CREATE TABLE \"app\".\"orders\""), "{sql}");
assert!(sql.contains("\"MixedCase\""), "{sql}");
assert!(sql.contains("420.00"), "{sql}");
assert!(!sql.contains("accounts"), "{sql}");
assert!(!sql.contains("events"), "{sql}");
// A view inside the same schema is not part of a table-name scope.
assert!(!sql.contains("big_orders"), "{sql}");
// Patterns are case-sensitive on PostgreSQL, and a scope that matches
// nothing must fail without leaving a partial file behind.
let mut empty = plan_with_id(dir.path(), "empty");
empty.config.connection_id = "postgres".into();
empty.config.databases = vec![database.clone()];
empty.config.output_compression = "gzip".into();
empty.config.table_filter_mode = "include".into();
empty.config.table_patterns = vec!["mixedcase".into()];
service.command(BackupCommand::Save { schedule: empty }).await.unwrap();
let cancelled = enqueue_and_run(&service, "empty").await;
assert_eq!(cancelled.status, "failed");
assert!(cancelled.error.as_deref().unwrap().contains("No tables matched"), "{:?}", cancelled.error);
assert!(cancelled.files.is_empty());
// Every schema of the database is exported when no table scope is set.
let mut full = plan_with_id(dir.path(), "full");
full.config.connection_id = "postgres".into();
full.config.databases = vec![database.clone()];
full.config.output_compression = "gzip".into();
service.command(BackupCommand::Save { schedule: full }).await.unwrap();
let full_run = enqueue_and_run(&service, "full").await;
assert_eq!(full_run.status, "success", "{:?}", full_run.error);
assert_eq!(full_run.files.len(), 3, "{:?}", full_run.files.iter().map(|f| &f.display_name).collect::<Vec<_>>());
let bodies =
full_run.files.iter().map(|f| (f.schema.clone(), read_backup(&f.file_path, true))).collect::<Vec<_>>();
let app = bodies.iter().find(|(schema, _)| schema == "app").unwrap();
assert!(app.1.contains("CREATE TABLE \"app\".\"MixedCase\""));
assert!(app.1.contains("big_orders"), "views belong to the schema-wide export");
let audit = bodies.iter().find(|(schema, _)| schema == "audit").unwrap();
assert!(audit.1.contains("sentinel-event"));
assert!(!audit.1.contains("orders"));
let public = bodies.iter().find(|(schema, _)| schema == "public").unwrap();
assert!(public.1.contains("accounts"));
stop.cancel();
worker.await.unwrap();
let cleanup = execute_sql_statement(
&service.state,
admin_id,
"postgres",
&format!("DROP DATABASE \"{database}\" WITH (FORCE)"),
None,
None,
)
.await;
service.state.shutdown(Duration::from_secs(3)).await;
cleanup.unwrap();
});
}
+30 -5
View File
@@ -312,8 +312,12 @@ fn add_mq_routes(router: Router<Arc<WebState>>) -> Router<Arc<WebState>> {
router
}
#[tokio::main]
async fn main() {
fn main() {
let runtime = dbx_core::scheduled_backup::worker_runtime().expect("Failed to build tokio runtime");
runtime.block_on(serve());
}
async fn serve() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -394,6 +398,7 @@ async fn main() {
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
managed_sql_previews: Default::default(),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
@@ -402,6 +407,15 @@ async fn main() {
});
ssh_prompt::install_web_ssh_prompt_bridge(web_state.ssh_prompts.clone());
routes::sql_file::start_sql_file_cleanup(&web_state);
let backup_root = std::env::var_os("DBX_BACKUP_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| web_state.data_dir.join("backups"));
std::fs::create_dir_all(&backup_root).expect("Failed to create server backup root");
let backup_service = routes::scheduled_backup::service(&web_state).expect("Failed to resolve server backup root");
let backup_stop = tokio_util::sync::CancellationToken::new();
let backup_worker = backup_service.start(backup_stop.clone());
// API routes
let api = Router::new()
@@ -409,6 +423,9 @@ async fn main() {
.route("/migration/start", post(routes::migration::start))
.route("/migration/retry", post(routes::migration::retry))
.route("/migration/cleanup-backups", post(routes::migration::cleanup_backups))
.route("/database-backups", post(routes::scheduled_backup::command))
.route("/database-backups/{id}/files/{index}", get(routes::scheduled_backup::download))
.route("/database-backups/{id}/files/{index}/restore", post(routes::scheduled_backup::prepare_restore))
// Auth
.route("/auth/login", post(auth::login))
.route("/auth/check", get(auth::check))
@@ -1137,6 +1154,7 @@ async fn main() {
.layer(DefaultBodyLimit::max(routes::sql_file::sql_file_upload_hard_cap_bytes())),
)
.route("/sql-file/execute", post(routes::sql_file::execute_sql_file))
.route("/sql-file/preview/release", post(routes::sql_file::release_sql_file_preview))
.route("/sql-file/tables", post(routes::sql_file::inspect_sql_file_tables))
.route("/sql-file/progress/{executionId}", get(routes::sql_file::sql_file_progress))
.route("/sql-file/cancel", post(routes::sql_file::cancel_sql_file))
@@ -1263,13 +1281,20 @@ async fn main() {
let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind address");
let shutdown_state = web_state.app.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::warn!("Failed to listen for shutdown signal: {error}");
.with_graceful_shutdown(async move {
#[cfg(unix)]
{
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to listen for SIGTERM");
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
}
#[cfg(not(unix))]
let _ = tokio::signal::ctrl_c().await;
backup_stop.cancel();
})
.await
.expect("Server error");
let _ = tokio::time::timeout(std::time::Duration::from_secs(60), backup_worker).await;
shutdown_state.shutdown(std::time::Duration::from_secs(3)).await;
}
+1
View File
@@ -30,6 +30,7 @@ pub mod query_result_export;
pub mod redis;
pub mod redis_pubsub_ws;
pub mod saved_sql;
pub mod scheduled_backup;
pub mod schema;
pub mod schema_cache;
pub mod schema_diff;
+1
View File
@@ -1567,6 +1567,7 @@ mod tests {
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
managed_sql_previews: Default::default(),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
@@ -0,0 +1,48 @@
use crate::{error::AppError, state::WebState};
use axum::{
body::Body,
extract::{Path, State},
http::header,
response::Response,
Json,
};
use dbx_core::scheduled_backup::{BackupCommand, BackupService};
use std::{path::PathBuf, sync::Arc};
use tokio_util::io::ReaderStream;
pub fn service(state: &WebState) -> Result<BackupService, String> {
let root = std::env::var_os("DBX_BACKUP_ROOT").map(PathBuf::from).unwrap_or_else(|| state.data_dir.join("backups"));
let root = root.canonicalize().map_err(|e| format!("Cannot resolve the server backup root: {e}"))?;
Ok(BackupService::new(state.app.clone(), &state.data_dir, Some(root)))
}
pub async fn command(
State(state): State<Arc<WebState>>,
Json(command): Json<BackupCommand>,
) -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(service(&state)?.command(command).await?))
}
pub async fn download(
State(state): State<Arc<WebState>>,
Path((id, index)): Path<(String, usize)>,
) -> Result<Response, AppError> {
let path = service(&state)?.file(&id, index).await?;
let name = path.file_name().unwrap_or_default().to_string_lossy();
let disposition = super::export_download::attachment_content_disposition(&name);
let file = tokio::fs::File::open(&path).await.map_err(|e| AppError::from(e.to_string()))?;
Response::builder()
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CACHE_CONTROL, "no-store")
.body(Body::from_stream(ReaderStream::new(file)))
.map_err(|e| AppError::from(e.to_string()))
}
pub async fn prepare_restore(
State(state): State<Arc<WebState>>,
Path((id, index)): Path<(String, usize)>,
) -> Result<Json<serde_json::Value>, AppError> {
let source = service(&state)?.file(&id, index).await?;
Ok(Json(super::sql_file::prepare_backup_preview(&state, source).await?))
}
+310 -46
View File
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::sync::{Arc, Mutex, Weak};
use std::time::{Duration, Instant};
use axum::extract::{Multipart, Path as AxumPath, State};
use axum::response::sse::{Event, Sse};
@@ -25,6 +26,119 @@ pub const SQL_FILE_UPLOAD_MAX_BYTES: usize = 200 * 1024 * 1024;
const SQL_FILE_UPLOAD_MIN_BYTES: usize = 1024 * 1024;
const SQL_FILE_UPLOAD_MAX_BYTES_LIMIT: usize = 4096 * 1024 * 1024;
struct ManagedPreview {
created: Instant,
directory: Weak<tempfile::TempDir>,
pending: Option<Arc<tempfile::TempDir>>,
}
#[derive(Default)]
pub struct ManagedSqlPreviews(Mutex<HashMap<String, ManagedPreview>>);
impl ManagedSqlPreviews {
fn insert(&self, directory: tempfile::TempDir) -> String {
let directory = Arc::new(directory);
let token = directory.path().file_name().unwrap().to_string_lossy().into_owned();
self.0.lock().unwrap().insert(
token.clone(),
ManagedPreview { created: Instant::now(), directory: Arc::downgrade(&directory), pending: Some(directory) },
);
token
}
fn release(&self, token: &str) {
let mut entries = self.0.lock().unwrap();
if entries.get(token).is_some_and(|entry| entry.pending.is_some()) {
entries.remove(token);
}
}
fn claim(&self, paths: &[PathBuf]) -> Result<Vec<Arc<tempfile::TempDir>>, AppError> {
let mut entries = self.0.lock().unwrap();
let mut tokens = HashSet::new();
for path in paths {
let Some(parent) = path.parent() else { continue };
let Some(token) = parent.file_name().and_then(|name| name.to_str()) else { continue };
if !token.starts_with("restore-") {
continue;
}
let valid = entries
.get(token)
.and_then(|entry| entry.pending.as_ref())
.is_some_and(|directory| directory.path().canonicalize().is_ok_and(|directory| directory == parent));
if !valid {
return Err(AppError::from("Backup preview expired or already consumed".to_string()));
}
tokens.insert(token.to_owned());
}
Ok(tokens.into_iter().map(|token| entries.get_mut(&token).unwrap().pending.take().unwrap()).collect())
}
fn cleanup(&self, tmp_dir: &Path, max_age: Duration, managed_only: bool) {
let mut entries = self.0.lock().unwrap();
for entry in entries.values_mut() {
if entry.created.elapsed() >= max_age {
entry.pending = None;
}
}
entries.retain(|_, entry| entry.directory.strong_count() > 0);
cleanup_sql_file_uploads_except(tmp_dir, max_age, &entries.keys().cloned().collect(), managed_only);
}
}
pub fn start_sql_file_cleanup(state: &Arc<WebState>) {
let state = Arc::downgrade(state);
tokio::spawn(async move {
loop {
let Some(state) = state.upgrade() else { break };
state.managed_sql_previews.cleanup(&state.data_dir.join("tmp/sql_file"), SQL_FILE_UPLOAD_MAX_AGE, true);
drop(state);
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseSqlFilePreviewRequest {
pub cleanup_token: String,
}
pub async fn release_sql_file_preview(
State(state): State<Arc<WebState>>,
Json(body): Json<ReleaseSqlFilePreviewRequest>,
) -> Json<()> {
state.managed_sql_previews.release(&body.cleanup_token);
Json(())
}
pub(super) async fn prepare_backup_preview(state: &WebState, source: PathBuf) -> Result<serde_json::Value, AppError> {
let tmp_dir = state.data_dir.join("tmp/sql_file");
std::fs::create_dir_all(&tmp_dir).map_err(|error| AppError::from(error.to_string()))?;
state.managed_sql_previews.cleanup(&tmp_dir, SQL_FILE_UPLOAD_MAX_AGE, true);
let limit = sql_file_upload_limit(state).await as u64;
let (directory, file_path, size) = tokio::task::spawn_blocking(move || {
let directory = tempfile::Builder::new().prefix(&format!("restore-{}-", Uuid::new_v4())).tempdir_in(tmp_dir)?;
let file_path =
directory.path().join(source.file_name().ok_or_else(|| std::io::Error::other("Invalid backup name"))?);
if std::fs::metadata(&source)?.len() > limit {
return Err(std::io::Error::other("Backup exceeds the SQL file upload limit"));
}
let size = std::fs::copy(source, &file_path)?;
if size > limit {
return Err(std::io::Error::other("Backup exceeds the SQL file upload limit"));
}
Ok((directory, file_path, size))
})
.await
.map_err(|error| AppError::from(error.to_string()))?
.map_err(|error: std::io::Error| AppError::from(error.to_string()))?;
let file_name = file_path.file_name().unwrap().to_string_lossy();
let mut preview = preview_uploaded_sql_file(&file_path, &file_name, size).await?;
preview["cleanupToken"] = state.managed_sql_previews.insert(directory).into();
Ok(preview)
}
pub fn sql_file_upload_max_bytes_from_value(value: Option<&str>) -> usize {
let mb = value
.and_then(|value| value.trim().parse::<usize>().ok())
@@ -82,7 +196,7 @@ pub async fn preview_sql_file(
) -> Result<Json<serde_json::Value>, AppError> {
let tmp_dir = state.data_dir.join("tmp").join("sql_file");
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?;
cleanup_expired_sql_file_uploads(&tmp_dir);
state.managed_sql_previews.cleanup(&tmp_dir, SQL_FILE_UPLOAD_MAX_AGE, false);
if let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? {
let file_name = field.file_name().unwrap_or("upload.sql").to_string();
@@ -95,48 +209,56 @@ pub async fn preview_sql_file(
let file_path = safe_uploaded_sql_path(&tmp_dir, &file_name)?;
std::fs::write(&file_path, &data).map_err(|e| AppError::from(e.to_string()))?;
if file_name.to_ascii_lowercase().ends_with(".zip") {
let extraction_dir = tmp_dir.join(format!("package-{}", Uuid::new_v4()));
let package = dbx_core::sql_file_zip_package::extract_sql_file_zip_package(&file_path, &extraction_dir)
.map_err(AppError::from)?;
let paths = dbx_core::sql_file_zip_package::extracted_sql_zip_paths(&extraction_dir, &package)
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let content =
sql::decode_sql_file_bytes(&std::fs::read(&paths[0]).map_err(|e| AppError::from(e.to_string()))?)
.map_err(AppError::from)?;
let preview: String = content.chars().take(20_000).collect();
let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content);
return Ok(Json(serde_json::json!({
"fileName": file_name,
"filePath": file_path.to_string_lossy(),
"sizeBytes": data.len(),
"preview": preview,
"canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database,
"establishesDatabaseContext": bootstrap_analysis.establishes_database_context,
"packageFilePaths": paths,
"packagePartCount": package.part_names.len(),
})));
}
return preview_uploaded_sql_file(&file_path, &file_name, data.len() as u64).await.map(Json);
}
let size_bytes = data.len() as u64;
let content =
dbx_core::sql_file_import::read_sql_file_preview(&file_path, 1_000_000).await.map_err(AppError::from)?;
Err(AppError::from("No file uploaded".to_string()))
}
async fn preview_uploaded_sql_file(
file_path: &Path,
file_name: &str,
size_bytes: u64,
) -> Result<serde_json::Value, AppError> {
let tmp_dir = file_path.parent().ok_or_else(|| AppError::from("Invalid SQL file path".to_string()))?;
if file_name.to_ascii_lowercase().ends_with(".zip") {
let extraction_dir = tmp_dir.join(format!("package-{}", Uuid::new_v4()));
let package = dbx_core::sql_file_zip_package::extract_sql_file_zip_package(file_path, &extraction_dir)
.map_err(AppError::from)?;
let paths = dbx_core::sql_file_zip_package::extracted_sql_zip_paths(&extraction_dir, &package)
.into_iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let content = sql::decode_sql_file_bytes(&std::fs::read(&paths[0]).map_err(|e| AppError::from(e.to_string()))?)
.map_err(AppError::from)?;
let preview: String = content.chars().take(20_000).collect();
let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content);
return Ok(Json(serde_json::json!({
return Ok(serde_json::json!({
"fileName": file_name,
"filePath": file_path.to_string_lossy(),
"sizeBytes": size_bytes,
"preview": preview,
"canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database,
"establishesDatabaseContext": bootstrap_analysis.establishes_database_context,
})));
"packageFilePaths": paths,
"packagePartCount": package.part_names.len(),
}));
}
Err(AppError::from("No file uploaded".to_string()))
let content =
dbx_core::sql_file_import::read_sql_file_preview(file_path, 1_000_000).await.map_err(AppError::from)?;
let preview: String = content.chars().take(20_000).collect();
let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content);
Ok(serde_json::json!({
"fileName": file_name,
"filePath": file_path.to_string_lossy(),
"sizeBytes": size_bytes,
"preview": preview,
"canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database,
"establishesDatabaseContext": bootstrap_analysis.establishes_database_context,
}))
}
pub async fn execute_sql_file(
@@ -145,6 +267,13 @@ pub async fn execute_sql_file(
) -> Result<Json<serde_json::Value>, AppError> {
let req = body.request;
let requested_paths = if body.file_paths.is_empty() { vec![req.file_path.clone()] } else { body.file_paths };
let file_paths = requested_paths
.iter()
.map(|file_path| validated_uploaded_sql_path(&state.data_dir, file_path))
.collect::<Result<Vec<_>, _>>()?;
let managed_previews = state.managed_sql_previews.claim(&file_paths)?;
// Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute)
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
return Err(AppError::from(format!(
@@ -154,14 +283,9 @@ pub async fn execute_sql_file(
}
let execution_id = req.execution_id.clone();
let requested_paths = if body.file_paths.is_empty() { vec![req.file_path.clone()] } else { body.file_paths };
if requested_paths.is_empty() {
return Err(AppError::from("No SQL files selected".to_string()));
}
let file_paths = requested_paths
.iter()
.map(|file_path| validated_uploaded_sql_path(&state.data_dir, file_path))
.collect::<Result<Vec<_>, _>>()?;
let token = CancellationToken::new();
{
@@ -180,6 +304,7 @@ pub async fn execute_sql_file(
let state_clone = state.clone();
tokio::spawn(async move {
let _managed_previews = managed_previews;
let started_at = std::time::Instant::now();
let mut progress_emitter = SqlFileProgressEmitter::new(|progress| {
send_sql_file_progress(&tx, progress);
@@ -276,15 +401,22 @@ fn safe_uploaded_sql_path(tmp_dir: &Path, file_name: &str) -> Result<PathBuf, Ap
Ok(tmp_dir.join(unique_name))
}
fn cleanup_expired_sql_file_uploads(tmp_dir: &Path) {
cleanup_sql_file_uploads_older_than(tmp_dir, SQL_FILE_UPLOAD_MAX_AGE);
#[cfg(test)]
fn cleanup_sql_file_uploads_older_than(tmp_dir: &Path, max_age: Duration) {
cleanup_sql_file_uploads_except(tmp_dir, max_age, &HashSet::new(), false);
}
fn cleanup_sql_file_uploads_older_than(tmp_dir: &Path, max_age: Duration) {
fn cleanup_sql_file_uploads_except(tmp_dir: &Path, max_age: Duration, active: &HashSet<String>, managed_only: bool) {
let Ok(entries) = std::fs::read_dir(tmp_dir) else {
return;
};
for entry in entries.flatten() {
if managed_only && !entry.file_name().to_string_lossy().starts_with("restore-") {
continue;
}
if active.contains(&entry.file_name().to_string_lossy().into_owned()) {
continue;
}
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
@@ -364,10 +496,142 @@ pub async fn cancel_sql_file(
mod tests {
use std::time::Duration;
use super::{
cleanup_sql_file_uploads_older_than, safe_uploaded_sql_path, sql_file_upload_max_bytes_from_value,
validated_uploaded_sql_path, SQL_FILE_UPLOAD_MAX_BYTES,
};
use super::*;
async fn restore_state(directory: &Path) -> WebState {
let storage = dbx_core::storage::Storage::open(&directory.join("dbx.db")).await.unwrap();
WebState::for_tests(Arc::new(dbx_core::connection::AppState::new(storage)), directory.to_path_buf())
}
#[tokio::test]
async fn restored_preview_uses_a_managed_copy_and_release_cannot_remove_the_backup() {
let directory = tempfile::tempdir().unwrap();
let state = restore_state(directory.path()).await;
let source = directory.path().join("backup.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let preview = prepare_backup_preview(&state, source.clone()).await.unwrap();
assert_eq!(preview["fileName"], "backup.sql");
assert_eq!(preview["preview"], "SELECT 1;");
let copy = PathBuf::from(preview["filePath"].as_str().unwrap());
assert!(copy.starts_with(directory.path().join("tmp/sql_file")));
assert_ne!(copy, source);
assert!(validated_uploaded_sql_path(directory.path(), copy.to_str().unwrap()).is_ok());
state.managed_sql_previews.release(source.to_str().unwrap());
assert!(source.exists());
assert!(copy.exists());
state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap());
state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap());
assert!(!copy.exists());
assert_eq!(std::fs::read_to_string(source).unwrap(), "SELECT 1;");
}
#[tokio::test]
async fn failed_backup_copy_and_gzip_preview_leave_no_managed_files() {
let directory = tempfile::tempdir().unwrap();
let state = restore_state(directory.path()).await;
assert!(prepare_backup_preview(&state, directory.path().join("missing.sql")).await.is_err());
let source = directory.path().join("invalid.sql.gz");
std::fs::write(&source, "not gzip").unwrap();
assert!(prepare_backup_preview(&state, source.clone()).await.is_err());
let oversized = directory.path().join("oversized.sql");
std::fs::File::create(&oversized).unwrap().set_len(sql_file_upload_limit(&state).await as u64 + 1).unwrap();
assert!(prepare_backup_preview(&state, oversized.clone()).await.is_err());
assert!(oversized.exists());
assert_eq!(std::fs::read_dir(directory.path().join("tmp/sql_file")).unwrap().count(), 0);
assert!(source.exists());
}
#[tokio::test]
async fn consumed_preview_survives_release_and_expiry_until_execution_finishes() {
let directory = tempfile::tempdir().unwrap();
let state = restore_state(directory.path()).await;
let source = directory.path().join("backup.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let preview = prepare_backup_preview(&state, source.clone()).await.unwrap();
let copy = PathBuf::from(preview["filePath"].as_str().unwrap());
let paths = vec![copy.canonicalize().unwrap()];
let execution = state.managed_sql_previews.claim(&paths).unwrap();
assert!(state.managed_sql_previews.claim(&paths).is_err());
state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap());
state.managed_sql_previews.cleanup(&directory.path().join("tmp/sql_file"), Duration::ZERO, true);
assert!(copy.exists());
drop(execution);
assert!(!copy.exists());
assert!(source.exists());
}
#[tokio::test]
async fn expired_and_abandoned_previews_are_removed_without_touching_ordinary_uploads() {
let directory = tempfile::tempdir().unwrap();
let state = restore_state(directory.path()).await;
let source = directory.path().join("backup.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let preview = prepare_backup_preview(&state, source.clone()).await.unwrap();
let tmp = directory.path().join("tmp/sql_file");
let abandoned = tmp.join("restore-abandoned");
std::fs::create_dir(&abandoned).unwrap();
std::fs::write(abandoned.join("backup.sql"), "SELECT 2;").unwrap();
let upload = tmp.join("upload.sql");
std::fs::write(&upload, "SELECT 3;").unwrap();
state.managed_sql_previews.cleanup(&tmp, Duration::ZERO, true);
assert!(!Path::new(preview["filePath"].as_str().unwrap()).exists());
assert!(!abandoned.exists());
assert!(upload.exists());
assert!(source.exists());
}
#[tokio::test]
async fn execution_failure_releases_the_managed_copy() {
let directory = tempfile::tempdir().unwrap();
let state = Arc::new(restore_state(directory.path()).await);
let source = directory.path().join("backup.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let preview = prepare_backup_preview(&state, source.clone()).await.unwrap();
let copy = PathBuf::from(preview["filePath"].as_str().unwrap());
let request = serde_json::from_value(serde_json::json!({
"executionId": "restore-error", "connectionId": "missing", "database": "app",
"filePath": copy, "continueOnError": false
}))
.unwrap();
let response =
execute_sql_file(State(state.clone()), Json(SqlFileExecuteWrapper { request, file_paths: vec![] }))
.await
.unwrap();
assert_eq!(response.0["executionId"], "restore-error");
tokio::time::timeout(Duration::from_secs(5), async {
while copy.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(source.exists());
assert!(!state.sql_file_executions.read().await.contains_key("restore-error"));
}
#[tokio::test]
async fn duplicate_execution_releases_the_new_preview_without_cancelling_the_existing_job() {
let directory = tempfile::tempdir().unwrap();
let state = Arc::new(restore_state(directory.path()).await);
let source = directory.path().join("backup.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let preview = prepare_backup_preview(&state, source.clone()).await.unwrap();
let copy = PathBuf::from(preview["filePath"].as_str().unwrap());
let token = CancellationToken::new();
state.sql_file_executions.write().await.insert("existing-job".into(), token.clone());
let request = serde_json::from_value(serde_json::json!({
"executionId": "existing-job", "connectionId": "missing", "database": "app",
"filePath": copy, "continueOnError": false
}))
.unwrap();
assert!(execute_sql_file(State(state.clone()), Json(SqlFileExecuteWrapper { request, file_paths: vec![] }))
.await
.is_err());
assert!(!copy.exists());
assert!(source.exists());
assert!(!token.is_cancelled());
assert!(state.sql_file_executions.read().await.contains_key("existing-job"));
}
#[test]
fn sql_file_upload_limit_defaults_and_clamps() {
+2
View File
@@ -39,6 +39,7 @@ pub struct WebState {
pub transfer_progress_channels: RwLock<HashMap<String, Arc<TransferProgressChannel>>>,
pub table_import_channels: RwLock<HashMap<String, watch::Sender<String>>>,
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
pub managed_sql_previews: crate::routes::sql_file::ManagedSqlPreviews,
pub nacos_imports: RwLock<HashMap<String, NacosImportContext>>,
pub login_rate_limit: Mutex<LoginRateLimit>,
/// Completed Web export temp files waiting for the browser download.
@@ -66,6 +67,7 @@ impl WebState {
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
managed_sql_previews: Default::default(),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),