Add incremental backup support (Phase 8)
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

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
This commit is contained in:
Warren
2026-06-24 04:20:33 +08:00
parent 2d8e9049b0
commit d76a200560
4 changed files with 103 additions and 2 deletions

View File

@@ -2753,6 +2753,7 @@ pub struct BackupConfigResponse {
pub compress: String,
pub encrypt: bool,
pub include_checksums: bool,
pub incremental: bool,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -2806,6 +2807,7 @@ async fn get_backup_config_handler() -> Json<BackupConfigResponse> {
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<BackupConfigResponse>) ->
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"}))

View File

@@ -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<bool, VfsError> {
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)?;