max / goingson
1 file changed,
+63 insertions,
-1 deletion
| @@ -14,6 +14,13 @@ use tracing::{debug, error, info, warn}; | |||
| 14 | 14 | /// Check interval for automated backups (1 minute) | |
| 15 | 15 | const CHECK_INTERVAL_SECS: u64 = 60; | |
| 16 | 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 | + | ||
| 17 | 24 | /// Build a unique backup filename. The second-granular timestamp keeps files | |
| 18 | 25 | /// human-sortable; the short random suffix prevents a manual backup and the | |
| 19 | 26 | /// scheduler firing in the same second from colliding and silently overwriting | |
| @@ -71,7 +78,7 @@ async fn check_and_backup(app: &tauri::AppHandle, state: &Arc<AppState>) -> Resu | |||
| 71 | 78 | let defaults = goingson_core::NewBackupSettings { | |
| 72 | 79 | auto_backup_enabled: true, | |
| 73 | 80 | backup_frequency_minutes: 15, | |
| 74 | - | max_backups_to_keep: 1, | |
| 81 | + | max_backups_to_keep: 10, | |
| 75 | 82 | }; | |
| 76 | 83 | state | |
| 77 | 84 | .backup_settings | |
| @@ -174,6 +181,10 @@ fn prune_old_backups(backup_dir: &std::path::Path, max_to_keep: usize) -> Result | |||
| 174 | 181 | return Ok(()); // Keep all backups | |
| 175 | 182 | } | |
| 176 | 183 | ||
| 184 | + | // Enforce the retention floor so an aggressive setting cannot delete the last | |
| 185 | + | // good backup. | |
| 186 | + | let max_to_keep = max_to_keep.max(MIN_BACKUPS_TO_KEEP); | |
| 187 | + | ||
| 177 | 188 | let mut backups: Vec<_> = std::fs::read_dir(backup_dir) | |
| 178 | 189 | .map_err(|e| format!("Failed to read backup directory: {}", e))? | |
| 179 | 190 | .filter_map(|entry| entry.ok()) | |
| @@ -290,4 +301,55 @@ mod tests { | |||
| 290 | 301 | assert_ne!(a, b, "same-second backups must get distinct filenames"); | |
| 291 | 302 | assert!(a.starts_with("goingson-backup-") && a.ends_with(".json.gz")); | |
| 292 | 303 | } | |
| 304 | + | ||
| 305 | + | /// Write `n` backup files, each stamped with a distinct increasing mtime so | |
| 306 | + | /// newest-first pruning is deterministic. | |
| 307 | + | fn seed_backups(dir: &std::path::Path, n: usize) { | |
| 308 | + | use std::time::{Duration, SystemTime}; | |
| 309 | + | let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000); | |
| 310 | + | for i in 0..n { | |
| 311 | + | let path = dir.join(format!("goingson-backup-{i:03}.json.gz")); | |
| 312 | + | std::fs::write(&path, [i as u8]).unwrap(); | |
| 313 | + | let f = std::fs::File::options().write(true).open(&path).unwrap(); | |
| 314 | + | f.set_modified(base + Duration::from_secs(i as u64)).unwrap(); | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | fn gz_count(dir: &std::path::Path) -> usize { | |
| 319 | + | std::fs::read_dir(dir) | |
| 320 | + | .unwrap() | |
| 321 | + | .filter_map(|e| e.ok()) | |
| 322 | + | .filter(|e| e.path().extension().map(|x| x == "gz").unwrap_or(false)) | |
| 323 | + | .count() | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | #[test] | |
| 327 | + | fn prune_enforces_retention_floor() { | |
| 328 | + | // Even with max_to_keep = 1, the floor keeps MIN_BACKUPS_TO_KEEP so a | |
| 329 | + | // single bad generation can never wipe out the last good backup. | |
| 330 | + | let dir = tempfile::tempdir().unwrap(); | |
| 331 | + | seed_backups(dir.path(), 6); | |
| 332 | + | prune_old_backups(dir.path(), 1).unwrap(); | |
| 333 | + | assert_eq!( | |
| 334 | + | gz_count(dir.path()), | |
| 335 | + | MIN_BACKUPS_TO_KEEP, | |
| 336 | + | "max_to_keep below the floor must be clamped up to the floor" | |
| 337 | + | ); | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | #[test] | |
| 341 | + | fn prune_keep_all_is_exempt_from_floor() { | |
| 342 | + | let dir = tempfile::tempdir().unwrap(); | |
| 343 | + | seed_backups(dir.path(), 5); | |
| 344 | + | prune_old_backups(dir.path(), 0).unwrap(); | |
| 345 | + | assert_eq!(gz_count(dir.path()), 5, "0 means keep everything"); | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | #[test] | |
| 349 | + | fn prune_above_floor_uses_configured_limit() { | |
| 350 | + | let dir = tempfile::tempdir().unwrap(); | |
| 351 | + | seed_backups(dir.path(), 9); | |
| 352 | + | prune_old_backups(dir.path(), 5).unwrap(); | |
| 353 | + | assert_eq!(gz_count(dir.path()), 5, "a limit above the floor is honored"); | |
| 354 | + | } | |
| 293 | 355 | } |