Skip to main content

max / goingson

13.1 KB · 363 lines History Blame Raw
1 //! Automated backup scheduler.
2 //!
3 //! Runs in the background and creates compressed backups based on user settings.
4 //! Handles backup retention by pruning old backups when max count is exceeded.
5
6 use crate::export::backup::{write_backup, FullExport};
7 use crate::state::{AppState, DESKTOP_USER_ID};
8 use chrono::Utc;
9 use std::sync::Arc;
10 use tauri::Manager;
11 use tokio_util::sync::CancellationToken;
12 use tracing::{debug, error, info, warn};
13
14 /// Check interval for automated backups (1 minute)
15 const CHECK_INTERVAL_SECS: u64 = 60;
16
17 /// Retention floor: never keep fewer than this many backups, regardless of the
18 /// configured `max_backups_to_keep`. With a 15-minute cadence and a single-backup
19 /// limit, one bad write (corruption, crash mid-write, a backup taken just after
20 /// accidental data loss) could otherwise leave no good generation to recover from.
21 /// A setting of 0 still means "keep everything" and is exempt.
22 const MIN_BACKUPS_TO_KEEP: usize = 3;
23
24 /// Build a unique backup filename. The second-granular timestamp keeps files
25 /// human-sortable; the short random suffix prevents a manual backup and the
26 /// scheduler firing in the same second from colliding and silently overwriting
27 /// each other (GO-11).
28 fn backup_filename(now: chrono::DateTime<Utc>) -> String {
29 let stamp = now.format("%Y%m%d-%H%M%S");
30 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
31 format!("goingson-backup-{stamp}-{suffix}.json.gz")
32 }
33
34 /// Collect every syncable collection into a `FullExport`.
35 ///
36 /// Single source of truth for what a backup contains, so the auto-backup, manual
37 /// backup, and JSON-export paths cannot drift in which tables they capture (the
38 /// drift behind the ultra-fuzz finding that auto-backup silently omitted
39 /// time_sessions, milestones, daily_notes, attachments, and sync_accounts).
40 pub(crate) async fn collect_full_export(
41 state: &AppState,
42 user_id: goingson_core::UserId,
43 ) -> Result<FullExport, goingson_core::CoreError> {
44 let projects = state.projects.list_all(user_id).await?;
45 let tasks = state.tasks.list_all(user_id).await?;
46 let events = state.events.list_all(user_id).await?;
47 let emails = state.emails.list_all(user_id, true).await?;
48 let contacts = state.contacts.list_all(user_id).await?;
49 let time_sessions = state.tasks.list_all_time_sessions(user_id).await?;
50 let milestones = state.milestones.list_all(user_id).await?;
51 let daily_notes = state.daily_notes.list_all(user_id).await?;
52 let attachments = state.attachments.list_all(user_id).await?;
53 let sync_accounts = state.sync_accounts.list_all(user_id).await?;
54 let saved_views = state.saved_views.list_all(user_id).await?;
55 let weekly_reviews = state.weekly_reviews.list_all(user_id).await?;
56 let monthly_goals = state.monthly_reviews.list_all_goals(user_id).await?;
57 let monthly_reflections = state.monthly_reviews.list_all_reflections(user_id).await?;
58 Ok(FullExport::new(
59 projects,
60 tasks,
61 events,
62 emails,
63 contacts,
64 time_sessions,
65 milestones,
66 daily_notes,
67 attachments,
68 sync_accounts,
69 saved_views,
70 weekly_reviews,
71 monthly_goals,
72 monthly_reflections,
73 ))
74 }
75
76 /// Starts the background backup scheduler that creates automatic backups
77 /// based on user settings and prunes old backups.
78 pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) {
79 info!(
80 "Starting backup scheduler (check interval: {}s)",
81 CHECK_INTERVAL_SECS
82 );
83 let mut interval = tokio::time::interval(std::time::Duration::from_secs(CHECK_INTERVAL_SECS));
84
85 // Skip the first immediate tick
86 interval.tick().await;
87
88 loop {
89 tokio::select! {
90 _ = cancel.cancelled() => {
91 info!("Backup scheduler shutting down");
92 break;
93 }
94 _ = interval.tick() => {}
95 }
96
97 // Get app state
98 let state = match app.try_state::<Arc<AppState>>() {
99 Some(s) => s,
100 None => {
101 debug!("App state not available, skipping backup check");
102 continue;
103 }
104 };
105
106 // Check if backup is needed and perform it
107 if let Err(e) = check_and_backup(&app, &state).await {
108 error!(error = %e, "Error in backup scheduler");
109 }
110 }
111 }
112
113 /// Checks if a backup is needed based on settings and performs it if necessary.
114 async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Result<(), String> {
115 // Get backup settings (create defaults if not set)
116 let settings = match state.backup_settings.get(DESKTOP_USER_ID).await {
117 Ok(Some(s)) => s,
118 Ok(None) => {
119 // Create default settings
120 let defaults = goingson_core::NewBackupSettings {
121 auto_backup_enabled: true,
122 backup_frequency_minutes: 15,
123 max_backups_to_keep: 10,
124 };
125 state
126 .backup_settings
127 .upsert(DESKTOP_USER_ID, defaults)
128 .await
129 .map_err(|e| e.to_string())?
130 }
131 Err(e) => return Err(format!("Failed to get backup settings: {}", e)),
132 };
133
134 // Check if auto backup is enabled
135 if !settings.auto_backup_enabled {
136 debug!("Auto backup is disabled");
137 return Ok(());
138 }
139
140 // Check if enough time has passed since last backup
141 let now = Utc::now();
142 let should_backup = match settings.last_backup_at {
143 Some(last) => {
144 let minutes_since = (now - last).num_minutes();
145 minutes_since >= settings.backup_frequency_minutes as i64
146 }
147 None => true, // Never backed up, do it now
148 };
149
150 if !should_backup {
151 debug!("Backup not needed yet");
152 return Ok(());
153 }
154
155 info!("Starting automated backup");
156
157 // Perform the backup
158 let backup_dir = app
159 .path()
160 .app_data_dir()
161 .map_err(|e| format!("Failed to get app data dir: {}", e))?
162 .join("backups");
163
164 let filename = backup_filename(now);
165 let file_path = backup_dir.join(&filename);
166
167 let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?;
168 let item_count = export.total_count();
169 let max_to_keep = settings.max_backups_to_keep as usize;
170
171 // Directory creation, gzip serialization, and pruning are all blocking and
172 // can take seconds on a large DB — run them on the blocking pool so the
173 // async reactor isn't stalled (email_sync uses the same pattern).
174 let log_path = file_path.clone();
175 let size = tokio::task::spawn_blocking(move || -> Result<u64, String> {
176 std::fs::create_dir_all(&backup_dir)
177 .map_err(|e| format!("Failed to create backup directory: {}", e))?;
178 let size = write_backup(&export, &file_path)
179 .map_err(|e| format!("Failed to write backup: {}", e))?;
180 prune_old_backups(&backup_dir, max_to_keep)?;
181 Ok(size)
182 })
183 .await
184 .map_err(|e| format!("Backup task panicked: {}", e))??;
185
186 info!(
187 path = %log_path.display(),
188 size_bytes = size,
189 items = item_count,
190 "Automated backup completed"
191 );
192
193 // Update last backup timestamp
194 state
195 .backup_settings
196 .update_last_backup_at(DESKTOP_USER_ID, now)
197 .await
198 .map_err(|e| format!("Failed to update last backup time: {}", e))?;
199
200 Ok(())
201 }
202
203 /// Removes old backups to maintain the maximum count.
204 fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result<(), String> {
205 if max_to_keep == 0 {
206 return Ok(()); // Keep all backups
207 }
208
209 // Enforce the retention floor so an aggressive setting cannot delete the last
210 // good backup.
211 let max_to_keep = max_to_keep.max(MIN_BACKUPS_TO_KEEP);
212
213 let mut backups: Vec<_> = std::fs::read_dir(backup_dir)
214 .map_err(|e| format!("Failed to read backup directory: {}", e))?
215 .filter_map(|entry| entry.ok())
216 .filter(|entry| {
217 entry
218 .path()
219 .extension()
220 .map(|ext| ext == "gz")
221 .unwrap_or(false)
222 })
223 .filter_map(|entry| {
224 entry
225 .metadata()
226 .ok()
227 .and_then(|m| m.created().or_else(|_| m.modified()).ok())
228 .map(|created| (entry.path(), created))
229 })
230 .collect();
231
232 // Sort by creation time, newest first
233 backups.sort_by_key(|b| std::cmp::Reverse(b.1));
234
235 // Remove backups beyond the limit
236 for (path, _) in backups.into_iter().skip(max_to_keep) {
237 info!(path = %path.display(), "Pruning old backup");
238 if let Err(e) = std::fs::remove_file(&path) {
239 warn!(path = %path.display(), error = %e, "Failed to remove old backup");
240 }
241 }
242
243 Ok(())
244 }
245
246 /// Performs an immediate backup (for manual trigger or on-demand).
247 pub async fn create_backup_now(
248 app: &tauri::AppHandle,
249 state: &Arc<AppState>,
250 ) -> Result<crate::commands::ExportResponse, String> {
251 let now = Utc::now();
252
253 let backup_dir = app
254 .path()
255 .app_data_dir()
256 .map_err(|e| format!("Failed to get app data dir: {}", e))?
257 .join("backups");
258
259 let filename = backup_filename(now);
260 let file_path = backup_dir.join(&filename);
261
262 let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?;
263 let item_count = export.total_count();
264
265 // Directory creation + gzip serialization are blocking and take seconds on a
266 // large DB; run them on the blocking pool so the manual "Backup now" action
267 // doesn't freeze the UI (the scheduled path already does this — Perf S6).
268 let backup_dir_task = backup_dir.clone();
269 let file_path_task = file_path.clone();
270 let size_bytes = tokio::task::spawn_blocking(move || -> Result<u64, String> {
271 std::fs::create_dir_all(&backup_dir_task)
272 .map_err(|e| format!("Failed to create backup directory: {}", e))?;
273 write_backup(&export, &file_path_task).map_err(|e| format!("Failed to write backup: {}", e))
274 })
275 .await
276 .map_err(|e| format!("Backup task panicked: {}", e))??;
277
278 // Update last backup timestamp
279 state
280 .backup_settings
281 .update_last_backup_at(DESKTOP_USER_ID, now)
282 .await
283 .map_err(|e| format!("Failed to update last backup time: {}", e))?;
284
285 // Prune old backups if settings exist
286 if let Ok(Some(settings)) = state.backup_settings.get(DESKTOP_USER_ID).await {
287 let _ = prune_old_backups(&backup_dir, settings.max_backups_to_keep as usize);
288 }
289
290 Ok(crate::commands::ExportResponse {
291 file_path: file_path.to_string_lossy().into_owned(),
292 item_count,
293 size_bytes,
294 })
295 }
296
297 #[cfg(test)]
298 mod tests {
299 use super::*;
300
301 #[test]
302 fn backup_filename_is_unique_within_the_same_second() {
303 // GO-11: a manual backup and the scheduler firing in the same second
304 // must not produce the same filename (which would silently overwrite).
305 let now = Utc::now();
306 let a = backup_filename(now);
307 let b = backup_filename(now);
308 assert_ne!(a, b, "same-second backups must get distinct filenames");
309 assert!(a.starts_with("goingson-backup-") && a.ends_with(".json.gz"));
310 }
311
312 /// Write `n` backup files, each stamped with a distinct increasing mtime so
313 /// newest-first pruning is deterministic.
314 fn seed_backups(dir: &std::path::Path, n: usize) {
315 use std::time::{Duration, SystemTime};
316 let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
317 for i in 0..n {
318 let path = dir.join(format!("goingson-backup-{i:03}.json.gz"));
319 std::fs::write(&path, [i as u8]).unwrap();
320 let f = std::fs::File::options().write(true).open(&path).unwrap();
321 f.set_modified(base + Duration::from_secs(i as u64)).unwrap();
322 }
323 }
324
325 fn gz_count(dir: &std::path::Path) -> usize {
326 std::fs::read_dir(dir)
327 .unwrap()
328 .filter_map(|e| e.ok())
329 .filter(|e| e.path().extension().map(|x| x == "gz").unwrap_or(false))
330 .count()
331 }
332
333 #[test]
334 fn prune_enforces_retention_floor() {
335 // Even with max_to_keep = 1, the floor keeps MIN_BACKUPS_TO_KEEP so a
336 // single bad generation can never wipe out the last good backup.
337 let dir = tempfile::tempdir().unwrap();
338 seed_backups(dir.path(), 6);
339 prune_old_backups(dir.path(), 1).unwrap();
340 assert_eq!(
341 gz_count(dir.path()),
342 MIN_BACKUPS_TO_KEEP,
343 "max_to_keep below the floor must be clamped up to the floor"
344 );
345 }
346
347 #[test]
348 fn prune_keep_all_is_exempt_from_floor() {
349 let dir = tempfile::tempdir().unwrap();
350 seed_backups(dir.path(), 5);
351 prune_old_backups(dir.path(), 0).unwrap();
352 assert_eq!(gz_count(dir.path()), 5, "0 means keep everything");
353 }
354
355 #[test]
356 fn prune_above_floor_uses_configured_limit() {
357 let dir = tempfile::tempdir().unwrap();
358 seed_backups(dir.path(), 9);
359 prune_old_backups(dir.path(), 5).unwrap();
360 assert_eq!(gz_count(dir.path()), 5, "a limit above the floor is honored");
361 }
362 }
363