mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-13 05:39:35 +08:00
fix(backup): 修复差异/清单设计评审发现的三处问题 (#92)
1) DeleteRecord 拒绝删除仍被成功差异依赖的全量(+CountDependentDifferentials),堵住手动删除孤立差异链的数据完整性缺口;2) 列表查询 Omit(Manifest),差异基线改按需 FindByID 加载,避免清单大列拖累热路径;3) FileRunner.Run 每文件即时关闭句柄,杜绝大目录 FD 泄漏。含仓储层单测。
This commit is contained in:
@@ -128,13 +128,19 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
}
|
||||
|
||||
if currentInfo.Mode().IsRegular() {
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
return err
|
||||
// 每个文件在独立作用域内打开并关闭,避免在大目录树中累积打开的文件句柄。
|
||||
if copyErr := func() error {
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
fileCount++
|
||||
if fileCount%100 == 0 {
|
||||
|
||||
@@ -45,6 +45,9 @@ func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.Ba
|
||||
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
|
||||
return r.records, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) CountDependentDifferentials(context.Context, uint) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
|
||||
|
||||
68
server/internal/repository/backup_record_manifest_test.go
Normal file
68
server/internal/repository/backup_record_manifest_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// 列表查询应省略 Manifest 列(避免拖出大 JSON),而 FindByID 仍保留(内容浏览/差异基线需要)。
|
||||
func TestListSuccessfulByTaskOmitsManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newBackupRecordTestRepository(t)
|
||||
rec := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, Manifest: `{"entries":[{"p":"x"}]}`, StartedAt: time.Now().UTC()}
|
||||
if err := repo.Create(ctx, rec); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
items, err := repo.ListSuccessfulByTask(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSuccessfulByTask: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected at least one record")
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Manifest != "" {
|
||||
t.Fatalf("ListSuccessfulByTask must omit Manifest, got %q", it.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
full, err := repo.FindByID(ctx, rec.ID)
|
||||
if err != nil || full == nil {
|
||||
t.Fatalf("FindByID: %v / %v", full, err)
|
||||
}
|
||||
if full.Manifest == "" {
|
||||
t.Fatal("FindByID must retain Manifest (browse/diff depend on it)")
|
||||
}
|
||||
}
|
||||
|
||||
// 仅统计「成功且依赖给定全量」的差异备份:失败的或依赖其他全量的不计入。
|
||||
func TestCountDependentDifferentials(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newBackupRecordTestRepository(t)
|
||||
now := time.Now().UTC()
|
||||
base := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, StartedAt: now}
|
||||
if err := repo.Create(ctx, base); err != nil {
|
||||
t.Fatalf("create base: %v", err)
|
||||
}
|
||||
mk := func(status, kind string, baseID uint) {
|
||||
if err := repo.Create(ctx, &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: status, BackupKind: kind, BaseRecordID: baseID, StartedAt: now}); err != nil {
|
||||
t.Fatalf("create dependent: %v", err)
|
||||
}
|
||||
}
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
|
||||
mk(model.BackupRecordStatusFailed, model.BackupKindDifferential, base.ID) // 失败 → 不计
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, 99999) // 依赖其他全量 → 不计
|
||||
|
||||
n, err := repo.CountDependentDifferentials(ctx, base.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountDependentDifferentials: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("want 2 dependent successful differentials, got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type BackupRecordRepository interface {
|
||||
ListRecent(context.Context, int) ([]model.BackupRecord, error)
|
||||
ListByTask(context.Context, uint) ([]model.BackupRecord, error)
|
||||
ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error)
|
||||
CountDependentDifferentials(context.Context, uint) (int64, error)
|
||||
Count(context.Context) (int64, error)
|
||||
CountSince(context.Context, time.Time) (int64, error)
|
||||
CountSuccessSince(context.Context, time.Time) (int64, error)
|
||||
@@ -57,7 +58,8 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
|
||||
}
|
||||
|
||||
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
|
||||
// Omit("Manifest"):列表不需要可能很大的清单 JSON,避免每行拖出该 TEXT 列。
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
|
||||
if options.TaskID != nil {
|
||||
query = query.Where("task_id = ?", *options.TaskID)
|
||||
}
|
||||
@@ -125,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
|
||||
limit = 10
|
||||
}
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
@@ -133,7 +135,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
|
||||
|
||||
func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
@@ -141,12 +143,21 @@ func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint
|
||||
|
||||
func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CountDependentDifferentials 统计依赖某全量记录(作为基线)的成功差异备份数量。
|
||||
func (r *GormBackupRecordRepository) CountDependentDifferentials(ctx context.Context, baseID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).
|
||||
Where("base_record_id = ? AND backup_kind = ? AND status = ?", baseID, model.BackupKindDifferential, model.BackupRecordStatusSuccess).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -280,6 +280,17 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
|
||||
return apperror.BadRequest("BACKUP_RECORD_LOCKED",
|
||||
"该备份已保留锁定(法律保留),请先解锁再删除", nil)
|
||||
}
|
||||
// 差异链保护:禁止删除仍被差异备份依赖的全量,否则这些差异将无法恢复(与保留清理的保护一致)。
|
||||
if record.BackupKind == model.BackupKindFull {
|
||||
deps, depErr := s.records.CountDependentDifferentials(ctx, record.ID)
|
||||
if depErr != nil {
|
||||
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法检查差异备份依赖", depErr)
|
||||
}
|
||||
if deps > 0 {
|
||||
return apperror.BadRequest("BACKUP_RECORD_HAS_DEPENDENTS",
|
||||
fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil)
|
||||
}
|
||||
}
|
||||
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
|
||||
return err
|
||||
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
||||
@@ -598,14 +609,19 @@ func (s *BackupExecutionService) resolveDifferentialBase(ctx context.Context, ta
|
||||
cutoff := time.Now().Add(-time.Duration(intervalDays) * 24 * time.Hour)
|
||||
for i := range records {
|
||||
rec := records[i]
|
||||
if rec.BackupKind != model.BackupKindFull || strings.TrimSpace(rec.Manifest) == "" {
|
||||
if rec.BackupKind != model.BackupKindFull {
|
||||
continue
|
||||
}
|
||||
// 最近的全量已超过强制全量间隔 → 触发新全量,限制差异链跨度与单个差异体积。
|
||||
if rec.StartedAt.Before(cutoff) {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
manifest, decErr := backup.DecodeManifest([]byte(rec.Manifest))
|
||||
// 列表查询已省略 Manifest 列,这里按需单独加载最近全量的清单(FindByID 含 Manifest)。
|
||||
full, ferr := s.records.FindByID(ctx, rec.ID)
|
||||
if ferr != nil || full == nil || strings.TrimSpace(full.Manifest) == "" {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
manifest, decErr := backup.DecodeManifest([]byte(full.Manifest))
|
||||
if decErr != nil || len(manifest.Entries) == 0 {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user