From d76a200560737a670fc29314c4d27f402a3dd8c2 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 24 Jun 2026 04:20:33 +0800 Subject: [PATCH] Add incremental backup support (Phase 8) BackupScheduler Enhancement: - Added incremental: bool field to BackupScheduleConfig - Default: incremental=true (enabled by default) - copy_incremental_to_snapshot() method - file_changed() detection (size + mtime comparison) - Hardlink unchanged files to base snapshot (ZFS-style) Incremental Backup Algorithm: 1. If incremental=true and previous snapshot exists: - Compare file size and mtime with base snapshot - If unchanged: create hardlink to base (zero disk usage) - If changed: copy and compress (new content) 2. If incremental=false or no previous snapshot: - Full copy (traditional backup) Storage Savings: - Unchanged files: hardlink (0 extra disk space) - Changed files: copy + compress (minimal overhead) - Similar to ZFS snapshot mechanism BackupConfigResponse Updated: - Added incremental field - Added compress field (GUI: dropdown select) Backup.vue Updated: - Incremental switch with explanation text - Compression dropdown (None/LZ4/ZSTD) - Default values loaded from backend REST API Test: curl /api/v2/backup/config {incremental:true,compress:zstd,...} Build: 495 tests pass --- data/auth.sqlite | Bin 81920 -> 81920 bytes markbase-core/src/server.rs | 3 + markbase-core/src/vfs/backup_scheduler.rs | 83 +++++++++++++++++++++- markbase-tauri/src/src/views/Backup.vue | 19 ++++- 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/data/auth.sqlite b/data/auth.sqlite index 9101e329d1883ce1e6849f2eb157b5bdccccaf1c..80243da7ab0e2bf149bdf4670f291448e7a7777c 100644 GIT binary patch delta 383 zcmZo@U~On%ogmG4WulBTn9P2iJ(f`0Nu%4BflZj&k z0|N_~0Ti{zD(e5=pRbt#32<#@Ech?K=>UrWb2E3?X7&rrj4a#WEXE(Dy04nCZ1!O4-?B|f;VU}hpE=^8OEiPtgM&>kae*Q8- zlreU)!~acS5B>ko&v7$dl|D1nHu**-Od&?F(DZscM(*tyc8oI^0pzTA AEdT%j delta 287 zcmZo@U~On%ogmG4YNCuYT zzsdb!dN6PE7y0M>k}G+cWtnpGb5rw5iYhr)aWacCrKINObFA3>MgIf8z)DtTP9~03 z3=Aw_22j)b$b>Ib0g>T z&Fl}rLU%bPpMA-*`SVL3QO4-W4*xfSo%a7fKjZD~U-%gzv^=ALfE+KMD+B*!{!RQ7 y`3v~{`StjD`QGrI=3B+r$(PROx>->{nU7h%k#TyUJ)`LKdOJq$?HP8AGZ+CkX Json { compress: compress_name.to_string(), encrypt: config.encrypt, include_checksums: config.include_checksums, + incremental: config.incremental, }) } @@ -2824,6 +2826,7 @@ async fn set_backup_config_handler(Json(config): Json) -> compress, encrypt: config.encrypt, include_checksums: config.include_checksums, + incremental: config.incremental, }; scheduler.set_config(new_config); Json(serde_json::json!({"success": true, "message": "Backup config updated"})) diff --git a/markbase-core/src/vfs/backup_scheduler.rs b/markbase-core/src/vfs/backup_scheduler.rs index 9308a9a..d626209 100644 --- a/markbase-core/src/vfs/backup_scheduler.rs +++ b/markbase-core/src/vfs/backup_scheduler.rs @@ -17,6 +17,7 @@ pub struct BackupScheduleConfig { pub compress: VfsCompression, pub encrypt: bool, pub include_checksums: bool, + pub incremental: bool, } impl Default for BackupScheduleConfig { @@ -29,6 +30,7 @@ impl Default for BackupScheduleConfig { compress: VfsCompression::Zstd, encrypt: false, include_checksums: true, + incremental: true, } } } @@ -122,7 +124,12 @@ impl BackupScheduler { let snapshot_dir = self.root.join(".snapshots").join(&name); self.backend.create_dir(&snapshot_dir, 0o755)?; - self.copy_root_to_snapshot(&snapshot_dir)?; + if self.config.incremental && !self.snapshots.is_empty() { + let base_snapshot = self.snapshots.last().unwrap(); + self.copy_incremental_to_snapshot(base_snapshot, &snapshot_dir)?; + } else { + self.copy_root_to_snapshot(&snapshot_dir)?; + } if self.config.include_checksums { self.generate_checksums(&snapshot_dir)?; @@ -140,6 +147,80 @@ impl BackupScheduler { Ok(name) } + fn copy_incremental_to_snapshot(&self, base: &str, snapshot_dir: &PathBuf) -> Result<(), VfsError> { + let base_dir = self.root.join(".snapshots").join(base); + + if !self.backend.exists(&base_dir) { + return self.copy_root_to_snapshot(snapshot_dir); + } + + let entries = self.backend.read_dir(&self.root)?; + + for entry in entries { + if entry.name == ".snapshots" || entry.name == ".checksums" { + continue; + } + + let src_path = self.root.join(&entry.name); + let dst_path = snapshot_dir.join(&entry.name); + let base_path = base_dir.join(&entry.name); + + if entry.stat.is_dir { + self.copy_directory_incremental(&src_path, &dst_path, &base_path)?; + } else { + let needs_copy = !self.backend.exists(&base_path) || + self.file_changed(&src_path, &base_path)?; + + if needs_copy { + self.copy_file(&src_path, &dst_path)?; + } else { + self.create_hard_link(&base_path, &dst_path)?; + } + } + } + + Ok(()) + } + + fn file_changed(&self, src: &PathBuf, base: &PathBuf) -> Result { + let src_stat = self.backend.stat(src)?; + let base_stat = self.backend.stat(base)?; + + Ok(src_stat.size != base_stat.size || + src_stat.mtime != base_stat.mtime) + } + + fn create_hard_link(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> { + self.backend.hard_link(src, dst) + } + + fn copy_directory_incremental(&self, src: &PathBuf, dst: &PathBuf, base: &PathBuf) -> Result<(), VfsError> { + self.backend.create_dir(dst, 0o755)?; + + let entries = self.backend.read_dir(src)?; + + for entry in entries { + let child_src = src.join(&entry.name); + let child_dst = dst.join(&entry.name); + let child_base = base.join(&entry.name); + + if entry.stat.is_dir { + self.copy_directory_incremental(&child_src, &child_dst, &child_base)?; + } else { + let needs_copy = !self.backend.exists(&child_base) || + self.file_changed(&child_src, &child_base)?; + + if needs_copy { + self.copy_file(&child_src, &child_dst)?; + } else { + self.create_hard_link(&child_base, &child_dst)?; + } + } + } + + Ok(()) + } + fn copy_root_to_snapshot(&self, snapshot_dir: &PathBuf) -> Result<(), VfsError> { let entries = self.backend.read_dir(&self.root)?; diff --git a/markbase-tauri/src/src/views/Backup.vue b/markbase-tauri/src/src/views/Backup.vue index 7f83923..b72a5f9 100644 --- a/markbase-tauri/src/src/views/Backup.vue +++ b/markbase-tauri/src/src/views/Backup.vue @@ -28,7 +28,11 @@ const backupConfig = ref({ enabled: false, interval_hours: 24, max_snapshots: 7, - auto_cleanup: true + auto_cleanup: true, + compress: 'zstd', + encrypt: false, + include_checksums: true, + incremental: true }) const schedulerStats = ref({ @@ -374,6 +378,19 @@ onMounted(async () => { + + + + Only copy changed files (hardlink unchanged) + + + + + + + + + Save Config