//! Automated backup scheduler. //! //! Runs in the background and creates compressed backups based on user settings. //! Handles backup retention by pruning old backups when max count is exceeded. use crate::export::backup::{write_backup, FullExport}; use crate::state::{AppState, DESKTOP_USER_ID}; use chrono::Utc; use std::sync::Arc; use tauri::Manager; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; /// Check interval for automated backups (1 minute) const CHECK_INTERVAL_SECS: u64 = 60; /// Retention floor: never keep fewer than this many backups, regardless of the /// configured `max_backups_to_keep`. With a 15-minute cadence and a single-backup /// limit, one bad write (corruption, crash mid-write, a backup taken just after /// accidental data loss) could otherwise leave no good generation to recover from. /// A setting of 0 still means "keep everything" and is exempt. const MIN_BACKUPS_TO_KEEP: usize = 3; /// Build a unique backup filename. The second-granular timestamp keeps files /// human-sortable; the short random suffix prevents a manual backup and the /// scheduler firing in the same second from colliding and silently overwriting /// each other (GO-11). fn backup_filename(now: chrono::DateTime) -> String { let stamp = now.format("%Y%m%d-%H%M%S"); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; format!("goingson-backup-{stamp}-{suffix}.json.gz") } /// Collect every syncable collection into a `FullExport`. /// /// Single source of truth for what a backup contains, so the auto-backup, manual /// backup, and JSON-export paths cannot drift in which tables they capture (the /// drift behind the ultra-fuzz finding that auto-backup silently omitted /// time_sessions, milestones, daily_notes, attachments, and sync_accounts). pub(crate) async fn collect_full_export( state: &AppState, user_id: goingson_core::UserId, ) -> Result { let projects = state.projects.list_all(user_id).await?; let tasks = state.tasks.list_all(user_id).await?; let events = state.events.list_all(user_id).await?; let emails = state.emails.list_all(user_id, true).await?; let contacts = state.contacts.list_all(user_id).await?; let time_sessions = state.tasks.list_all_time_sessions(user_id).await?; let milestones = state.milestones.list_all(user_id).await?; let daily_notes = state.daily_notes.list_all(user_id).await?; let attachments = state.attachments.list_all(user_id).await?; let sync_accounts = state.sync_accounts.list_all(user_id).await?; let saved_views = state.saved_views.list_all(user_id).await?; let weekly_reviews = state.weekly_reviews.list_all(user_id).await?; let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?; let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?; Ok(FullExport::new( projects, tasks, events, emails, contacts, time_sessions, milestones, daily_notes, attachments, sync_accounts, saved_views, weekly_reviews, monthly_goals, monthly_reflections, )) } /// Starts the background backup scheduler that creates automatic backups /// based on user settings and prunes old backups. pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) { info!( "Starting backup scheduler (check interval: {}s)", CHECK_INTERVAL_SECS ); let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS)); // Skip the first immediate tick interval.tick().await; loop { tokio::select! { _ = cancel.cancelled() => { info!("Backup scheduler shutting down"); break; } _ = interval.tick() => {} } // Get app state let state = match app.try_state::>() { Some(s) => s, None => { debug!("App state not available, skipping backup check"); continue; } }; // Check if backup is needed and perform it if let Err(e) = check_and_backup(&app, &state).await { error!(error = %e, "Error in backup scheduler"); } } } /// Checks if a backup is needed based on settings and performs it if necessary. async fn check_and_backup(app: &tauri::AppHandle, state: &Arc) -> Result<(), String> { // Get backup settings (create defaults if not set) let settings = match state.backup_settings.get(DESKTOP_USER_ID).await { Ok(Some(s)) => s, Ok(None) => { // Create default settings let defaults = goingson_core::NewBackupSettings { auto_backup_enabled: true, backup_frequency_minutes: 15, max_backups_to_keep: 10, }; state .backup_settings .upsert(DESKTOP_USER_ID, defaults) .await .map_err(|e| e.to_string())? } Err(e) => return Err(format!("Failed to get backup settings: {}", e)), }; // Check if auto backup is enabled if !settings.auto_backup_enabled { debug!("Auto backup is disabled"); return Ok(()); } // Check if enough time has passed since last backup let now = Utc::now(); let should_backup = match settings.last_backup_at { Some(last) => { let minutes_since = (now - last).num_minutes(); minutes_since >= settings.backup_frequency_minutes as i64 } None => true, // Never backed up, do it now }; if !should_backup { debug!("Backup not needed yet"); return Ok(()); } info!("Starting automated backup"); // Perform the backup let backup_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))? .join("backups"); let filename = backup_filename(now); let file_path = backup_dir.join(&filename); let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; let item_count = export.total_count(); let max_to_keep = settings.max_backups_to_keep as usize; // Directory creation, gzip serialization, and pruning are all blocking and // can take seconds on a large DB — run them on the blocking pool so the // async reactor isn't stalled (email_sync uses the same pattern). let log_path = file_path.clone(); let size = tokio::task::spawn_blocking(move || -> Result { std::fs::create_dir_all(&backup_dir) .map_err(|e| format!("Failed to create backup directory: {}", e))?; let size = write_backup(&export, &file_path) .map_err(|e| format!("Failed to write backup: {}", e))?; prune_old_backups(&backup_dir, max_to_keep)?; Ok(size) }) .await .map_err(|e| format!("Backup task panicked: {}", e))??; info!( path = %log_path.display(), size_bytes = size, items = item_count, "Automated backup completed" ); // Update last backup timestamp state .backup_settings .update_last_backup_at(DESKTOP_USER_ID, now) .await .map_err(|e| format!("Failed to update last backup time: {}", e))?; Ok(()) } /// Removes old backups to maintain the maximum count. fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result<(), String> { if max_to_keep == 0 { return Ok(()); // Keep all backups } // Enforce the retention floor so an aggressive setting cannot delete the last // good backup. let max_to_keep = max_to_keep.max(MIN_BACKUPS_TO_KEEP); let mut backups: Vec<_> = std::fs::read_dir(backup_dir) .map_err(|e| format!("Failed to read backup directory: {}", e))? .filter_map(|entry| entry.ok()) .filter(|entry| { entry .path() .extension() .map(|ext| ext == "gz") .unwrap_or(false) }) .filter_map(|entry| { entry .metadata() .ok() .and_then(|m| m.created().or_else(|_| m.modified()).ok()) .map(|created| (entry.path(), created)) }) .collect(); // Sort by creation time, newest first backups.sort_by_key(|b| std::cmp::Reverse(b.1)); // Remove backups beyond the limit for (path, _) in backups.into_iter().skip(max_to_keep) { info!(path = %path.display(), "Pruning old backup"); if let Err(e) = std::fs::remove_file(&path) { warn!(path = %path.display(), error = %e, "Failed to remove old backup"); } } Ok(()) } /// Performs an immediate backup (for manual trigger or on-demand). pub async fn create_backup_now( app: &tauri::AppHandle, state: &Arc, ) -> Result { let now = Utc::now(); let backup_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))? .join("backups"); let filename = backup_filename(now); let file_path = backup_dir.join(&filename); let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; let item_count = export.total_count(); // Directory creation + gzip serialization are blocking and take seconds on a // large DB; run them on the blocking pool so the manual "Backup now" action // doesn't freeze the UI (the scheduled path already does this — Perf S6). let backup_dir_task = backup_dir.clone(); let file_path_task = file_path.clone(); let size_bytes = tokio::task::spawn_blocking(move || -> Result { std::fs::create_dir_all(&backup_dir_task) .map_err(|e| format!("Failed to create backup directory: {}", e))?; write_backup(&export, &file_path_task).map_err(|e| format!("Failed to write backup: {}", e)) }) .await .map_err(|e| format!("Backup task panicked: {}", e))??; // Update last backup timestamp state .backup_settings .update_last_backup_at(DESKTOP_USER_ID, now) .await .map_err(|e| format!("Failed to update last backup time: {}", e))?; // Prune old backups if settings exist if let Ok(Some(settings)) = state.backup_settings.get(DESKTOP_USER_ID).await { let _ = prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize); } Ok(crate::commands::ExportResponse { file_path: file_path.to_string_lossy().into_owned(), item_count, size_bytes, }) } #[cfg(test)] mod tests { use super::*; #[test] fn backup_filename_is_unique_within_the_same_second() { // GO-11: a manual backup and the scheduler firing in the same second // must not produce the same filename (which would silently overwrite). let now = Utc::now(); let a = backup_filename(now); let b = backup_filename(now); assert_ne!(a, b, "same-second backups must get distinct filenames"); assert!(a.starts_with("goingson-backup-") && a.ends_with(".json.gz")); } /// Write `n` backup files, each stamped with a distinct increasing mtime so /// newest-first pruning is deterministic. fn seed_backups(dir: &std::path::Path, n: usize) { use std::time::{Duration, SystemTime}; let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); for i in 0..n { let path = dir.join(format!("goingson-backup-{i:03}.json.gz")); std::fs::write(&path, [i as u8]).unwrap(); let f = std::fs::File::options().write(true).open(&path).unwrap(); f.set_modified(base + Duration::from_secs(i as u64)).unwrap(); } } fn gz_count(dir: &std::path::Path) -> usize { std::fs::read_dir(dir) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map(|x| x == "gz").unwrap_or(false)) .count() } #[test] fn prune_enforces_retention_floor() { // Even with max_to_keep = 1, the floor keeps MIN_BACKUPS_TO_KEEP so a // single bad generation can never wipe out the last good backup. let dir = tempfile::tempdir().unwrap(); seed_backups(dir.path(), 6); prune_old_backups(dir.path(), 1).unwrap(); assert_eq!( gz_count(dir.path()), MIN_BACKUPS_TO_KEEP, "max_to_keep below the floor must be clamped up to the floor" ); } #[test] fn prune_keep_all_is_exempt_from_floor() { let dir = tempfile::tempdir().unwrap(); seed_backups(dir.path(), 5); prune_old_backups(dir.path(), 0).unwrap(); assert_eq!(gz_count(dir.path()), 5, "0 means keep everything"); } #[test] fn prune_above_floor_uses_configured_limit() { let dir = tempfile::tempdir().unwrap(); seed_backups(dir.path(), 9); prune_old_backups(dir.path(), 5).unwrap(); assert_eq!(gz_count(dir.path()), 5, "a limit above the floor is honored"); } }