mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-05 23:47:33 +08:00
feat(backup): 新增 CDC 内容寻址仓库模式
使用内容定义分块、不可变 pack 与快照索引实现跨文件和跨快照去重。 接入多目标上传、软配额、恢复、下载、校验、保留和安全垃圾回收,并补充前端入口、中英文文档及端到端测试。 Closes #94
This commit is contained in:
@@ -12,12 +12,22 @@ When a task is routed to a remote Agent, the source tools and paths are resolved
|
|||||||
|
|
||||||
## File / Directory
|
## File / Directory
|
||||||
|
|
||||||
Tars (and optionally gzips) one or more filesystem paths.
|
File tasks offer three backup modes:
|
||||||
|
|
||||||
|
- **Full archive** — writes a self-contained tar artifact on every run
|
||||||
|
- **Differential archive** — writes only changes since the current full baseline and periodically refreshes that baseline
|
||||||
|
- **CDC repository** — splits content with stable 512 KiB / 1 MiB / 4 MiB boundaries, stores new chunks in immutable 32 MiB packs, and writes a small snapshot manifest for each run
|
||||||
|
|
||||||
|
The CDC repository deduplicates identical content across files and snapshots. Restore, selective restore, verification, download-as-tar, retention, and garbage collection all resolve data through the repository index. Compression and encryption are applied per chunk; encrypted repositories use keyed chunk IDs so plaintext hashes are not exposed.
|
||||||
|
|
||||||
|
Repository mode currently uses a single-writer index and therefore runs on the Master only. To keep repository copies on multiple backends, select multiple primary storage targets on the task. Object-level replication is intentionally disabled because a snapshot manifest without its shared packs and indexes is not a complete backup.
|
||||||
|
|
||||||
|
Common file-task options:
|
||||||
|
|
||||||
- **Source** accepts multiple paths — one per line in the UI
|
- **Source** accepts multiple paths — one per line in the UI
|
||||||
- **Exclude patterns** accept gitignore-style globs
|
- **Exclude patterns** accept gitignore-style globs
|
||||||
- Supports following symlinks, preserving permissions
|
- Supports following symlinks, preserving permissions
|
||||||
- Output is a single `.tar` or `.tar.gz` artifact
|
- Full and differential modes output `.tar`, `.tar.gz`, or `.tar.zst` artifacts
|
||||||
|
|
||||||
## MySQL
|
## MySQL
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -12,12 +12,22 @@ BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行
|
|||||||
|
|
||||||
## 文件 / 目录
|
## 文件 / 目录
|
||||||
|
|
||||||
打包(可选 gzip)一个或多个文件系统路径。
|
文件任务提供三种备份模式:
|
||||||
|
|
||||||
|
- **全量归档** — 每次生成一份可独立使用的 tar 产物
|
||||||
|
- **差异归档** — 只保存相对当前全量基线的变化,并按周期刷新全量基线
|
||||||
|
- **CDC 去重仓库** — 按稳定的 512 KiB / 1 MiB / 4 MiB 内容边界切块,将新块合并到不可变的 32 MiB pack,每次运行只新增一份小型快照清单
|
||||||
|
|
||||||
|
CDC 仓库会在不同文件、不同快照之间复用相同内容。完整恢复、选择性恢复、完整性校验、下载为 tar、保留策略和垃圾回收都通过仓库索引定位分块。压缩与加密按块执行;启用加密时使用带密钥的块 ID,不暴露明文哈希。
|
||||||
|
|
||||||
|
当前仓库索引采用单写者模型,因此 CDC 模式仅在 Master 本机执行。如需保存多份完整仓库,请在任务中直接多选主存储目标。对象级副本复制会被禁用,因为只有快照清单、没有共享 pack 与索引并不是完整备份。
|
||||||
|
|
||||||
|
文件任务的通用选项:
|
||||||
|
|
||||||
- **源路径** 支持多个(UI 中每行一个)
|
- **源路径** 支持多个(UI 中每行一个)
|
||||||
- **排除模式** 支持 gitignore 风格的通配符
|
- **排除模式** 支持 gitignore 风格的通配符
|
||||||
- 可选跟随符号链接、保留权限
|
- 可选跟随符号链接、保留权限
|
||||||
- 输出单个 `.tar` 或 `.tar.gz`
|
- 全量与差异模式输出 `.tar`、`.tar.gz` 或 `.tar.zst`
|
||||||
|
|
||||||
## MySQL
|
## MySQL
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
||||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
|
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
|
||||||
logHub := backup.NewLogHub()
|
logHub := backup.NewLogHub()
|
||||||
retentionService := backupretention.NewService(backupRecordRepo)
|
retentionService := backupretention.NewService(backupRecordRepo, configCipher.Key())
|
||||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
||||||
authService.SetNotificationService(notificationService)
|
authService.SetNotificationService(notificationService)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
repositoryChunkMin = 512 << 10
|
||||||
|
repositoryChunkAvg = 1 << 20
|
||||||
|
repositoryChunkMax = 4 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentDefinedChunker implements the normalized FastCDC cut-point strategy.
|
||||||
|
// The rolling Gear hash only retains the latest 64 bytes through uint64
|
||||||
|
// overflow, so chunk boundaries re-synchronize after insertions or deletions.
|
||||||
|
type contentDefinedChunker struct {
|
||||||
|
minSize int
|
||||||
|
avgSize int
|
||||||
|
maxSize int
|
||||||
|
smallMask uint64
|
||||||
|
largeMask uint64
|
||||||
|
gear [256]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newContentDefinedChunker() *contentDefinedChunker {
|
||||||
|
chunker := &contentDefinedChunker{
|
||||||
|
minSize: repositoryChunkMin,
|
||||||
|
avgSize: repositoryChunkAvg,
|
||||||
|
maxSize: repositoryChunkMax,
|
||||||
|
smallMask: (1 << 21) - 1,
|
||||||
|
largeMask: (1 << 19) - 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitMix64 produces a stable, well-distributed Gear table. The seed and
|
||||||
|
// generation algorithm are part of repository format v1 and must not change.
|
||||||
|
seed := uint64(0x6a09e667f3bcc909)
|
||||||
|
for i := range chunker.gear {
|
||||||
|
seed += 0x9e3779b97f4a7c15
|
||||||
|
value := seed
|
||||||
|
value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9
|
||||||
|
value = (value ^ (value >> 27)) * 0x94d049bb133111eb
|
||||||
|
chunker.gear[i] = value ^ (value >> 31)
|
||||||
|
}
|
||||||
|
return chunker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentDefinedChunker) Split(ctx context.Context, reader io.Reader, emit func([]byte) error) error {
|
||||||
|
if reader == nil || emit == nil {
|
||||||
|
return fmt.Errorf("chunk reader and emitter are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := make([]byte, 0, c.maxSize+(256<<10))
|
||||||
|
readBuffer := make([]byte, 256<<10)
|
||||||
|
eof := false
|
||||||
|
for {
|
||||||
|
if !eof {
|
||||||
|
readCount, readErr := reader.Read(readBuffer)
|
||||||
|
if readCount > 0 {
|
||||||
|
pending = append(pending, readBuffer[:readCount]...)
|
||||||
|
}
|
||||||
|
switch readErr {
|
||||||
|
case nil:
|
||||||
|
case io.EOF:
|
||||||
|
eof = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("read source for chunking: %w", readErr)
|
||||||
|
}
|
||||||
|
if readCount == 0 && readErr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(pending) > 0 {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cut := c.findCutPoint(pending, eof)
|
||||||
|
if cut == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunk := make([]byte, cut)
|
||||||
|
copy(chunk, pending[:cut])
|
||||||
|
if err := emit(chunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copy(pending, pending[cut:])
|
||||||
|
pending = pending[:len(pending)-cut]
|
||||||
|
}
|
||||||
|
|
||||||
|
if eof {
|
||||||
|
if len(pending) != 0 {
|
||||||
|
return fmt.Errorf("chunker stopped with %d buffered bytes", len(pending))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *contentDefinedChunker) findCutPoint(data []byte, eof bool) int {
|
||||||
|
if len(data) < c.minSize {
|
||||||
|
if eof {
|
||||||
|
return len(data)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := len(data)
|
||||||
|
if limit > c.maxSize {
|
||||||
|
limit = c.maxSize
|
||||||
|
}
|
||||||
|
var hash uint64
|
||||||
|
for index := c.minSize; index < limit; index++ {
|
||||||
|
hash = (hash << 1) + c.gear[data[index]]
|
||||||
|
mask := c.largeMask
|
||||||
|
if index < c.avgSize {
|
||||||
|
mask = c.smallMask
|
||||||
|
}
|
||||||
|
if hash&mask == 0 {
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(data) >= c.maxSize {
|
||||||
|
return c.maxSize
|
||||||
|
}
|
||||||
|
if eof {
|
||||||
|
return len(data)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"backupx/server/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContentDefinedChunkerResynchronizesAfterInsertion(t *testing.T) {
|
||||||
|
source := make([]byte, 8<<20)
|
||||||
|
if _, err := rand.New(rand.NewSource(42)).Read(source); err != nil {
|
||||||
|
t.Fatalf("generate source: %v", err)
|
||||||
|
}
|
||||||
|
modified := make([]byte, 0, len(source)+4096)
|
||||||
|
modified = append(modified, source[:2<<20]...)
|
||||||
|
modified = append(modified, bytes.Repeat([]byte("inserted"), 512)...)
|
||||||
|
modified = append(modified, source[2<<20:]...)
|
||||||
|
|
||||||
|
chunker := newContentDefinedChunker()
|
||||||
|
collect := func(data []byte) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
ids := make(map[string]struct{})
|
||||||
|
err := chunker.Split(context.Background(), bytes.NewReader(data), func(chunk []byte) error {
|
||||||
|
digest := sha256.Sum256(chunk)
|
||||||
|
ids[fmt.Sprintf("%x", digest[:])] = struct{}{}
|
||||||
|
if len(chunk) > repositoryChunkMax {
|
||||||
|
return fmt.Errorf("chunk exceeds maximum: %d", len(chunk))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split chunks: %v", err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
originalChunks := collect(source)
|
||||||
|
modifiedChunks := collect(modified)
|
||||||
|
shared := 0
|
||||||
|
for chunkID := range originalChunks {
|
||||||
|
if _, ok := modifiedChunks[chunkID]; ok {
|
||||||
|
shared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared < len(originalChunks)/2 {
|
||||||
|
t.Fatalf("content-defined boundaries did not resynchronize: shared=%d original=%d", shared, len(originalChunks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryRoundTripDedupAndPrune(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(tempDir, "dataset")
|
||||||
|
if err := os.MkdirAll(filepath.Join(sourceDir, "empty"), 0o755); err != nil {
|
||||||
|
t.Fatalf("create source: %v", err)
|
||||||
|
}
|
||||||
|
original := make([]byte, 6<<20)
|
||||||
|
if _, err := rand.New(rand.NewSource(7)).Read(original); err != nil {
|
||||||
|
t.Fatalf("generate fixture: %v", err)
|
||||||
|
}
|
||||||
|
primaryPath := filepath.Join(sourceDir, "primary.bin")
|
||||||
|
duplicatePath := filepath.Join(sourceDir, "duplicate.bin")
|
||||||
|
if err := os.WriteFile(primaryPath, original, 0o640); err != nil {
|
||||||
|
t.Fatalf("write primary: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(duplicatePath, original, 0o640); err != nil {
|
||||||
|
t.Fatalf("write duplicate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := sha256.Sum256([]byte("repository-test-key"))
|
||||||
|
store := NewRepositoryStore(key[:])
|
||||||
|
provider := newMemoryRepositoryProvider()
|
||||||
|
task := TaskSpec{
|
||||||
|
ID: 12,
|
||||||
|
Name: "repository-test",
|
||||||
|
Type: "file",
|
||||||
|
SourcePaths: []string{sourceDir},
|
||||||
|
Compression: "zstd",
|
||||||
|
Encrypt: true,
|
||||||
|
StartedAt: time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC),
|
||||||
|
TempDir: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
firstPlan, err := store.BuildPlan(ctx, task, NopLogWriter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build first plan: %v", err)
|
||||||
|
}
|
||||||
|
firstKey := store.SnapshotKey(task.ID, 1, task.StartedAt)
|
||||||
|
firstResult, err := store.Upload(ctx, provider, firstPlan, firstKey)
|
||||||
|
if closeErr := firstPlan.Close(); closeErr != nil {
|
||||||
|
t.Fatalf("close first plan: %v", closeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload first snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if firstResult.NewChunks == 0 || firstResult.UniqueChunks == 0 {
|
||||||
|
t.Fatalf("first upload did not create chunks: %+v", firstResult)
|
||||||
|
}
|
||||||
|
if firstPlan.UniqueSize >= firstPlan.LogicalSize {
|
||||||
|
t.Fatalf("duplicate file was not deduplicated within plan: unique=%d logical=%d", firstPlan.UniqueSize, firstPlan.LogicalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := make([]byte, 0, len(original)+4096)
|
||||||
|
modified = append(modified, original[:2<<20]...)
|
||||||
|
modified = append(modified, bytes.Repeat([]byte("changed!"), 512)...)
|
||||||
|
modified = append(modified, original[2<<20:]...)
|
||||||
|
if err := os.WriteFile(primaryPath, modified, 0o640); err != nil {
|
||||||
|
t.Fatalf("modify primary: %v", err)
|
||||||
|
}
|
||||||
|
task.StartedAt = task.StartedAt.Add(time.Hour)
|
||||||
|
secondPlan, err := store.BuildPlan(ctx, task, NopLogWriter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build second plan: %v", err)
|
||||||
|
}
|
||||||
|
secondKey := store.SnapshotKey(task.ID, 2, task.StartedAt)
|
||||||
|
secondResult, err := store.Upload(ctx, provider, secondPlan, secondKey)
|
||||||
|
if closeErr := secondPlan.Close(); closeErr != nil {
|
||||||
|
t.Fatalf("close second plan: %v", closeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload second snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if secondResult.ReusedBytes <= secondResult.LogicalSize/3 {
|
||||||
|
t.Fatalf("second snapshot reused too little data: %+v", secondResult)
|
||||||
|
}
|
||||||
|
if secondResult.UploadedBytes >= secondResult.LogicalSize {
|
||||||
|
t.Fatalf("incremental upload was not smaller than logical data: %+v", secondResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
verify, err := store.Verify(ctx, provider, secondKey, secondResult.Checksum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify repository: %v", err)
|
||||||
|
}
|
||||||
|
if verify.Chunks == 0 || verify.Bytes == 0 {
|
||||||
|
t.Fatalf("empty verification result: %+v", verify)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreRoot := filepath.Join(tempDir, "restore")
|
||||||
|
restoreTask := task
|
||||||
|
restoreTask.RestoreTargetPath = restoreRoot
|
||||||
|
if err := store.Restore(ctx, provider, secondKey, restoreTask, NopLogWriter{}); err != nil {
|
||||||
|
t.Fatalf("restore snapshot: %v", err)
|
||||||
|
}
|
||||||
|
restored, err := os.ReadFile(filepath.Join(restoreRoot, filepath.Base(sourceDir), "primary.bin"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored primary: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(restored, modified) {
|
||||||
|
t.Fatalf("restored primary differs from source")
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(filepath.Join(restoreRoot, filepath.Base(sourceDir), "empty")); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("empty directory was not restored: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := provider.Delete(ctx, firstKey); err != nil {
|
||||||
|
t.Fatalf("delete first snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Prune(ctx, provider); err != nil {
|
||||||
|
t.Fatalf("prune with live snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if err := provider.Delete(ctx, secondKey); err != nil {
|
||||||
|
t.Fatalf("delete second snapshot: %v", err)
|
||||||
|
}
|
||||||
|
pruned, err := store.Prune(ctx, provider)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prune empty repository: %v", err)
|
||||||
|
}
|
||||||
|
if pruned.DeletedPacks == 0 || pruned.DeletedIndexes == 0 {
|
||||||
|
t.Fatalf("prune did not reclaim repository data: %+v", pruned)
|
||||||
|
}
|
||||||
|
if objects, err := provider.List(ctx, repositoryPackPrefix); err != nil || len(objects) != 0 {
|
||||||
|
t.Fatalf("packs remain after prune: objects=%v err=%v", objects, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryRepositoryProvider struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
objects map[string][]byte
|
||||||
|
times map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryRepositoryProvider() *memoryRepositoryProvider {
|
||||||
|
return &memoryRepositoryProvider{objects: make(map[string][]byte), times: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) Type() storage.ProviderType { return "memory" }
|
||||||
|
func (p *memoryRepositoryProvider) TestConnection(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) Upload(_ context.Context, key string, reader io.Reader, size int64, _ map[string]string) error {
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if int64(len(data)) != size {
|
||||||
|
return fmt.Errorf("size mismatch for %s: %d != %d", key, len(data), size)
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.objects[key] = append([]byte(nil), data...)
|
||||||
|
p.times[key] = time.Now().UTC()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) Download(_ context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
data, ok := p.objects[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("object %s not found", key)
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(append([]byte(nil), data...))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) DownloadRange(_ context.Context, key string, offset, length int64) (io.ReadCloser, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
data, ok := p.objects[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("object %s not found", key)
|
||||||
|
}
|
||||||
|
if offset < 0 || length <= 0 || offset+length > int64(len(data)) {
|
||||||
|
return nil, fmt.Errorf("invalid range %d:%d for %s", offset, length, key)
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(append([]byte(nil), data[offset:offset+length]...))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) Delete(_ context.Context, key string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if _, ok := p.objects[key]; !ok {
|
||||||
|
return fmt.Errorf("object %s not found", key)
|
||||||
|
}
|
||||||
|
delete(p.objects, key)
|
||||||
|
delete(p.times, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memoryRepositoryProvider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
result := make([]storage.ObjectInfo, 0)
|
||||||
|
for key, data := range p.objects {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
result = append(result, storage.ObjectInfo{Key: key, Size: int64(len(data)), UpdatedAt: p.times[key]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key })
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ package retention
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backupx/server/internal/backup"
|
||||||
"backupx/server/internal/model"
|
"backupx/server/internal/model"
|
||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/storage"
|
"backupx/server/internal/storage"
|
||||||
@@ -40,16 +42,48 @@ type CleanupResult struct {
|
|||||||
Warnings []string
|
Warnings []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type cleanupObject struct {
|
||||||
records repository.BackupRecordRepository
|
targetID uint
|
||||||
now func() time.Time
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(records repository.BackupRecordRepository) *Service {
|
type storedUploadResult struct {
|
||||||
return &Service{records: records, now: func() time.Time { return time.Now().UTC() }}
|
StorageTargetID uint `json:"storageTargetId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StoragePath string `json:"storagePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
records repository.BackupRecordRepository
|
||||||
|
repositoryKey []byte
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(records repository.BackupRecordRepository, repositoryKey ...[]byte) *Service {
|
||||||
|
var key []byte
|
||||||
|
if len(repositoryKey) > 0 {
|
||||||
|
key = append([]byte(nil), repositoryKey[0]...)
|
||||||
|
}
|
||||||
|
return &Service{records: records, repositoryKey: key, now: func() time.Time { return time.Now().UTC() }}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
|
func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider storage.StorageProvider) (*CleanupResult, error) {
|
||||||
|
return s.cleanup(ctx, task, func(uint) (storage.StorageProvider, bool) {
|
||||||
|
return provider, provider != nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupProviders applies one retention decision to every successful copy of
|
||||||
|
// a record before deleting its database row. This prevents multi-target tasks
|
||||||
|
// from leaving stale objects after the first target removes the shared record.
|
||||||
|
func (s *Service) CleanupProviders(ctx context.Context, task *model.BackupTask, providers map[uint]storage.StorageProvider) (*CleanupResult, error) {
|
||||||
|
return s.cleanup(ctx, task, func(targetID uint) (storage.StorageProvider, bool) {
|
||||||
|
provider, ok := providers[targetID]
|
||||||
|
return provider, ok && provider != nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) cleanup(ctx context.Context, task *model.BackupTask, resolveProvider func(uint) (storage.StorageProvider, bool)) (*CleanupResult, error) {
|
||||||
if task == nil {
|
if task == nil {
|
||||||
return nil, fmt.Errorf("backup task is required")
|
return nil, fmt.Errorf("backup task is required")
|
||||||
}
|
}
|
||||||
@@ -67,17 +101,35 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
|||||||
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
|
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
|
||||||
candidates = protectDifferentialBases(records, candidates)
|
candidates = protectDifferentialBases(records, candidates)
|
||||||
result := &CleanupResult{}
|
result := &CleanupResult{}
|
||||||
|
repositoryProviders := make(map[uint]storage.StorageProvider)
|
||||||
|
touchedProviders := make(map[uint]storage.StorageProvider)
|
||||||
for _, record := range candidates {
|
for _, record := range candidates {
|
||||||
if strings.TrimSpace(record.StoragePath) != "" {
|
objects, objectErr := cleanupObjectsForRecord(record)
|
||||||
if provider == nil {
|
if objectErr != nil {
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider for cleanup", record.ID))
|
result.Warnings = append(result.Warnings, fmt.Sprintf("decode storage copies for record %d failed: %v", record.ID, objectErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allObjectsDeleted := true
|
||||||
|
for _, object := range objects {
|
||||||
|
provider, ok := resolveProvider(object.targetID)
|
||||||
|
if !ok {
|
||||||
|
result.Warnings = append(result.Warnings, fmt.Sprintf("record %d missing storage provider %d for cleanup", record.ID, object.targetID))
|
||||||
|
allObjectsDeleted = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := provider.Delete(ctx, record.StoragePath); err != nil {
|
if err := provider.Delete(ctx, object.path); err != nil {
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s failed: %v", record.StoragePath, err))
|
result.Warnings = append(result.Warnings, fmt.Sprintf("delete storage object %s from target %d failed: %v", object.path, object.targetID, err))
|
||||||
|
allObjectsDeleted = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.DeletedObjects++
|
result.DeletedObjects++
|
||||||
|
touchedProviders[object.targetID] = provider
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
repositoryProviders[object.targetID] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allObjectsDeleted {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if err := s.records.Delete(ctx, record.ID); err != nil {
|
if err := s.records.Delete(ctx, record.ID); err != nil {
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
|
result.Warnings = append(result.Warnings, fmt.Sprintf("delete backup record %d failed: %v", record.ID, err))
|
||||||
@@ -85,13 +137,25 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
|||||||
}
|
}
|
||||||
result.DeletedRecords++
|
result.DeletedRecords++
|
||||||
}
|
}
|
||||||
|
for targetID, provider := range repositoryProviders {
|
||||||
|
pruned, pruneErr := backup.NewRepositoryStore(s.repositoryKey).Prune(ctx, provider)
|
||||||
|
if pruneErr != nil {
|
||||||
|
result.Warnings = append(result.Warnings, fmt.Sprintf("prune CDC repository on target %d failed: %v", targetID, pruneErr))
|
||||||
|
} else {
|
||||||
|
result.DeletedObjects += pruned.DeletedPacks + pruned.DeletedIndexes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
|
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
|
||||||
if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 {
|
for targetID, provider := range touchedProviders {
|
||||||
|
dirCleaner, ok := provider.(storage.StorageDirCleaner)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
prefixes := collectDirPrefixes(candidates)
|
prefixes := collectDirPrefixes(candidates)
|
||||||
for _, prefix := range prefixes {
|
for _, prefix := range prefixes {
|
||||||
if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil {
|
if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil {
|
||||||
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s: %v", prefix, err))
|
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s on target %d: %v", prefix, targetID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +163,43 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupObjectsForRecord(record model.BackupRecord) ([]cleanupObject, error) {
|
||||||
|
defaultPath := strings.TrimSpace(record.StoragePath)
|
||||||
|
if strings.TrimSpace(record.StorageUploadResults) == "" {
|
||||||
|
if defaultPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []cleanupObject{{targetID: record.StorageTargetID, path: defaultPath}}, nil
|
||||||
|
}
|
||||||
|
var results []storedUploadResult
|
||||||
|
if err := json.Unmarshal([]byte(record.StorageUploadResults), &results); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
objects := make([]cleanupObject, 0, len(results))
|
||||||
|
seen := make(map[uint]struct{}, len(results))
|
||||||
|
for _, result := range results {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(result.Status), model.BackupRecordStatusSuccess) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
objectPath := strings.TrimSpace(result.StoragePath)
|
||||||
|
if objectPath == "" {
|
||||||
|
objectPath = defaultPath
|
||||||
|
}
|
||||||
|
if objectPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[result.StorageTargetID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[result.StorageTargetID] = struct{}{}
|
||||||
|
objects = append(objects, cleanupObject{targetID: result.StorageTargetID, path: objectPath})
|
||||||
|
}
|
||||||
|
if len(objects) == 0 && defaultPath != "" {
|
||||||
|
return nil, fmt.Errorf("successful record has no successful storage copy")
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
|
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
|
||||||
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
|
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
|
||||||
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
|
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
|
||||||
|
|||||||
@@ -221,3 +221,66 @@ func TestCleanupDeletesExpiredRecords(t *testing.T) {
|
|||||||
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
|
t.Fatalf("unexpected deleted objects: %#v", provider.deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanupProvidersDeletesEverySuccessfulCopyBeforeRecord(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||||
|
completedNew := now.Add(-time.Hour)
|
||||||
|
completedOld := now.Add(-24 * time.Hour)
|
||||||
|
repo := &fakeRecordRepository{records: []model.BackupRecord{
|
||||||
|
{ID: 2, TaskID: 1, StoragePath: "records/2", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedNew},
|
||||||
|
{
|
||||||
|
ID: 1, TaskID: 1, StoragePath: "records/1", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedOld,
|
||||||
|
StorageUploadResults: `[{"storageTargetId":11,"status":"success","storagePath":"first/1"},{"storageTargetId":12,"status":"success","storagePath":"second/1"},{"storageTargetId":13,"status":"failed"}]`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
first := &fakeProvider{}
|
||||||
|
second := &fakeProvider{}
|
||||||
|
service := NewService(repo)
|
||||||
|
service.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
result, err := service.CleanupProviders(context.Background(), &model.BackupTask{ID: 1, MaxBackups: 1}, map[uint]storage.StorageProvider{
|
||||||
|
11: first,
|
||||||
|
12: second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupProviders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.DeletedRecords != 1 || result.DeletedObjects != 2 || len(result.Warnings) != 0 {
|
||||||
|
t.Fatalf("unexpected cleanup result: %#v", result)
|
||||||
|
}
|
||||||
|
if len(repo.deleted) != 1 || repo.deleted[0] != 1 {
|
||||||
|
t.Fatalf("unexpected deleted records: %#v", repo.deleted)
|
||||||
|
}
|
||||||
|
if len(first.deleted) != 1 || first.deleted[0] != "first/1" {
|
||||||
|
t.Fatalf("unexpected first-target deletes: %#v", first.deleted)
|
||||||
|
}
|
||||||
|
if len(second.deleted) != 1 || second.deleted[0] != "second/1" {
|
||||||
|
t.Fatalf("unexpected second-target deletes: %#v", second.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupProvidersKeepsRecordWhenCopyProviderIsUnavailable(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||||
|
completedNew := now.Add(-time.Hour)
|
||||||
|
completedOld := now.Add(-24 * time.Hour)
|
||||||
|
repo := &fakeRecordRepository{records: []model.BackupRecord{
|
||||||
|
{ID: 2, TaskID: 1, StoragePath: "records/2", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedNew},
|
||||||
|
{
|
||||||
|
ID: 1, TaskID: 1, StoragePath: "records/1", Status: model.BackupRecordStatusSuccess, CompletedAt: &completedOld,
|
||||||
|
StorageUploadResults: `[{"storageTargetId":11,"status":"success","storagePath":"first/1"},{"storageTargetId":12,"status":"success","storagePath":"second/1"}]`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
first := &fakeProvider{}
|
||||||
|
service := NewService(repo)
|
||||||
|
|
||||||
|
result, err := service.CleanupProviders(context.Background(), &model.BackupTask{ID: 1, MaxBackups: 1}, map[uint]storage.StorageProvider{11: first})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupProviders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.DeletedRecords != 0 || result.DeletedObjects != 1 || len(result.Warnings) != 1 {
|
||||||
|
t.Fatalf("unexpected safe partial-cleanup result: %#v", result)
|
||||||
|
}
|
||||||
|
if len(repo.deleted) != 0 {
|
||||||
|
t.Fatalf("record must remain until all copies are deleted: %#v", repo.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// BackupKindFull 全量备份;BackupKindDifferential 差异备份(仅含自基线全量以来的变更)。
|
// BackupKindFull 全量归档;BackupKindDifferential 差异归档;
|
||||||
|
// BackupKindRepository 为可独立恢复的 CDC 内容寻址快照。
|
||||||
BackupKindFull = "full"
|
BackupKindFull = "full"
|
||||||
BackupKindDifferential = "differential"
|
BackupKindDifferential = "differential"
|
||||||
|
BackupKindRepository = "repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BackupRecord struct {
|
type BackupRecord struct {
|
||||||
@@ -33,7 +35,7 @@ type BackupRecord struct {
|
|||||||
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
|
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
|
||||||
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
|
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
|
||||||
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
|
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
|
||||||
// BackupKind 备份类型:full(全量)/ differential(差异)。
|
// BackupKind 备份类型:full(全量)/ differential(差异)/ repository(CDC 快照)。
|
||||||
BackupKind string `gorm:"column:backup_kind;size:16;not null;default:'full';index" json:"backupKind"`
|
BackupKind string `gorm:"column:backup_kind;size:16;not null;default:'full';index" json:"backupKind"`
|
||||||
// BaseRecordID 差异备份所基于的全量备份记录 ID(全量记录为 0)。
|
// BaseRecordID 差异备份所基于的全量备份记录 ID(全量记录为 0)。
|
||||||
BaseRecordID uint `gorm:"column:base_record_id;index;not null;default:0" json:"baseRecordId"`
|
BaseRecordID uint `gorm:"column:base_record_id;index;not null;default:0" json:"baseRecordId"`
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// BackupModeFull 全量模式(默认);BackupModeDifferential 差异模式(仅文件类型本机任务)。
|
// BackupModeFull 全量模式(默认);BackupModeDifferential 差异归档;
|
||||||
|
// BackupModeRepository 为 CDC 内容寻址仓库模式(仅文件类型本机任务)。
|
||||||
BackupModeFull = "full"
|
BackupModeFull = "full"
|
||||||
BackupModeDifferential = "differential"
|
BackupModeDifferential = "differential"
|
||||||
|
BackupModeRepository = "repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -55,7 +57,8 @@ type BackupTask struct {
|
|||||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||||
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
||||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||||
// BackupMode 备份模式:full(全量,默认)/ differential(差异)。差异仅支持本机文件任务。
|
// BackupMode 备份模式:full(全量,默认)/ differential(差异归档)/
|
||||||
|
// repository(FastCDC 分块、全局去重快照)。后两者仅支持本机文件任务。
|
||||||
BackupMode string `gorm:"column:backup_mode;size:16;not null;default:'full'" json:"backupMode"`
|
BackupMode string `gorm:"column:backup_mode;size:16;not null;default:'full'" json:"backupMode"`
|
||||||
// DiffFullIntervalDays 差异模式下强制全量的间隔(天):最近全量超过该天数则本次自动改为全量,
|
// DiffFullIntervalDays 差异模式下强制全量的间隔(天):最近全量超过该天数则本次自动改为全量,
|
||||||
// 限制差异链跨度与单个差异体积。默认 7。
|
// 限制差异链跨度与单个差异体积。默认 7。
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -62,6 +64,17 @@ type DownloadedArtifact struct {
|
|||||||
Reader io.ReadCloser
|
Reader io.ReadCloser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type temporaryArtifactReader struct {
|
||||||
|
*os.File
|
||||||
|
directory string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *temporaryArtifactReader) Close() error {
|
||||||
|
closeErr := r.File.Close()
|
||||||
|
removeErr := os.RemoveAll(r.directory)
|
||||||
|
return errors.Join(closeErr, removeErr)
|
||||||
|
}
|
||||||
|
|
||||||
// collectTargetIDs 获取任务关联的所有存储目标 ID
|
// collectTargetIDs 获取任务关联的所有存储目标 ID
|
||||||
func collectTargetIDs(task *model.BackupTask) []uint {
|
func collectTargetIDs(task *model.BackupTask) []uint {
|
||||||
if len(task.StorageTargets) > 0 {
|
if len(task.StorageTargets) > 0 {
|
||||||
@@ -102,6 +115,10 @@ type BackupExecutionService struct {
|
|||||||
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
|
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
|
||||||
metrics *metrics.Metrics
|
metrics *metrics.Metrics
|
||||||
taskLocks sync.Map
|
taskLocks sync.Map
|
||||||
|
// repositoryLocks serializes immutable index updates per storage target.
|
||||||
|
// Repository mode is intentionally single-writer in v1 to avoid orphaned
|
||||||
|
// duplicate packs when two local tasks discover the same missing chunk.
|
||||||
|
repositoryLocks sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMetrics 注入 Prometheus 采集器。nil 时所有埋点退化为 no-op。
|
// SetMetrics 注入 Prometheus 采集器。nil 时所有埋点退化为 no-op。
|
||||||
@@ -211,6 +228,25 @@ func (s *BackupExecutionService) DownloadRecord(ctx context.Context, recordID ui
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
tempDir, err := os.MkdirTemp(s.tempDir, "repository-download-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法创建 CDC 导出目录", err)
|
||||||
|
}
|
||||||
|
exportName := fmt.Sprintf("backupx-record-%d.tar", record.ID)
|
||||||
|
exportPath := filepath.Join(tempDir, exportName)
|
||||||
|
store := backup.NewRepositoryStore(s.cipher.Key())
|
||||||
|
if err := store.ExportTar(ctx, provider, record.StoragePath, exportPath); err != nil {
|
||||||
|
cleanupErr := os.RemoveAll(tempDir)
|
||||||
|
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法从 CDC 仓库导出归档", errors.Join(err, cleanupErr))
|
||||||
|
}
|
||||||
|
file, err := os.Open(exportPath)
|
||||||
|
if err != nil {
|
||||||
|
cleanupErr := os.RemoveAll(tempDir)
|
||||||
|
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法打开 CDC 导出归档", errors.Join(err, cleanupErr))
|
||||||
|
}
|
||||||
|
return &DownloadedArtifact{FileName: exportName, Reader: &temporaryArtifactReader{File: file, directory: tempDir}}, nil
|
||||||
|
}
|
||||||
reader, err := provider.Download(ctx, record.StoragePath)
|
reader, err := provider.Download(ctx, record.StoragePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法下载备份文件", err)
|
return nil, apperror.Internal("BACKUP_RECORD_DOWNLOAD_FAILED", "无法下载备份文件", err)
|
||||||
@@ -234,6 +270,16 @@ func (s *BackupExecutionService) RestoreRecord(ctx context.Context, recordID uin
|
|||||||
if task == nil {
|
if task == nil {
|
||||||
return apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在,无法执行恢复", fmt.Errorf("backup task %d not found", record.TaskID))
|
return apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在,无法执行恢复", fmt.Errorf("backup task %d not found", record.TaskID))
|
||||||
}
|
}
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
spec, specErr := s.buildTaskSpec(task, record.StartedAt)
|
||||||
|
if specErr != nil {
|
||||||
|
return specErr
|
||||||
|
}
|
||||||
|
if err := backup.NewRepositoryStore(s.cipher.Key()).Restore(ctx, provider, record.StoragePath, spec, backup.NopLogWriter{}); err != nil {
|
||||||
|
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "从 CDC 仓库恢复备份失败", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
tempDir, err := os.MkdirTemp("", "backupx-restore-*")
|
tempDir, err := os.MkdirTemp("", "backupx-restore-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法创建恢复目录", err)
|
return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法创建恢复目录", err)
|
||||||
@@ -291,6 +337,58 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
|
|||||||
fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil)
|
fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
copies := []StorageUploadResultItem{{
|
||||||
|
StorageTargetID: record.StorageTargetID,
|
||||||
|
Status: model.BackupRecordStatusSuccess,
|
||||||
|
StoragePath: record.StoragePath,
|
||||||
|
}}
|
||||||
|
if strings.TrimSpace(record.StorageUploadResults) != "" {
|
||||||
|
if err := json.Unmarshal([]byte(record.StorageUploadResults), &copies); err != nil {
|
||||||
|
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法解析 CDC 仓库副本信息,已停止删除以避免遗留数据", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copyPaths := make(map[uint]string, len(copies))
|
||||||
|
for _, copy := range copies {
|
||||||
|
if strings.EqualFold(copy.Status, model.BackupRecordStatusSuccess) && strings.TrimSpace(copy.StoragePath) != "" {
|
||||||
|
copyPaths[copy.StorageTargetID] = copy.StoragePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetIDs := make([]uint, 0, len(copyPaths))
|
||||||
|
for targetID := range copyPaths {
|
||||||
|
targetIDs = append(targetIDs, targetID)
|
||||||
|
}
|
||||||
|
sort.Slice(targetIDs, func(i, j int) bool { return targetIDs[i] < targetIDs[j] })
|
||||||
|
unlocks := make([]func(), 0, len(targetIDs))
|
||||||
|
for _, targetID := range targetIDs {
|
||||||
|
unlocks = append(unlocks, s.acquireRepositoryLock(targetID))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for index := len(unlocks) - 1; index >= 0; index-- {
|
||||||
|
unlocks[index]()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
providers := make(map[uint]storage.StorageProvider, len(targetIDs))
|
||||||
|
for _, targetID := range targetIDs {
|
||||||
|
provider, resolveErr := s.resolveProvider(ctx, targetID)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return resolveErr
|
||||||
|
}
|
||||||
|
if deleteErr := provider.Delete(ctx, copyPaths[targetID]); deleteErr != nil {
|
||||||
|
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法删除 CDC 仓库快照", deleteErr)
|
||||||
|
}
|
||||||
|
providers[targetID] = provider
|
||||||
|
}
|
||||||
|
for targetID, provider := range providers {
|
||||||
|
if _, pruneErr := backup.NewRepositoryStore(s.cipher.Key()).Prune(ctx, provider); pruneErr != nil {
|
||||||
|
return apperror.Internal("BACKUP_REPOSITORY_PRUNE_FAILED", fmt.Sprintf("无法清理存储目标 %d 的 CDC 仓库;记录暂时保留以便重试", targetID), pruneErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.records.Delete(ctx, recordID); err != nil {
|
||||||
|
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法删除备份记录", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
|
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
||||||
@@ -384,6 +482,9 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
|
|||||||
return nil, perr
|
return nil, perr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(task.BackupMode, model.BackupModeRepository) && s.resolveRemoteNode(ctx, resolvedNodeID) != nil {
|
||||||
|
return nil, apperror.BadRequest("BACKUP_TASK_REPOSITORY_REMOTE_UNSUPPORTED", "CDC 仓库模式当前仅支持 Master 本机单写者执行", nil)
|
||||||
|
}
|
||||||
startedAt := s.now()
|
startedAt := s.now()
|
||||||
// 取第一个存储目标 ID 做兼容
|
// 取第一个存储目标 ID 做兼容
|
||||||
primaryTargetID := task.StorageTargetID
|
primaryTargetID := task.StorageTargetID
|
||||||
@@ -630,6 +731,141 @@ func (s *BackupExecutionService) resolveDifferentialBase(ctx context.Context, ta
|
|||||||
return 0, backup.Manifest{}, false
|
return 0, backup.Manifest{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type repositoryTaskResult struct {
|
||||||
|
fileName string
|
||||||
|
logicalSize int64
|
||||||
|
checksum string
|
||||||
|
storagePath string
|
||||||
|
storageTargetID uint
|
||||||
|
manifestJSON string
|
||||||
|
uploadResults []StorageUploadResultItem
|
||||||
|
providers map[uint]storage.StorageProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupExecutionService) executeRepositoryTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, spec backup.TaskSpec, logger *backup.ExecutionLogger) (*repositoryTaskResult, error) {
|
||||||
|
store := backup.NewRepositoryStore(s.cipher.Key())
|
||||||
|
plan, err := store.BuildPlan(ctx, spec, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := plan.Close(); closeErr != nil {
|
||||||
|
logger.Warnf("清理 CDC 临时计划失败:%v", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
manifestBytes, err := backup.EncodeManifest(plan.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode repository manifest: %w", err)
|
||||||
|
}
|
||||||
|
targetIDs := collectTargetIDs(task)
|
||||||
|
if len(targetIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("没有关联的存储目标")
|
||||||
|
}
|
||||||
|
storageUsage, usageErr := s.storageUsageSnapshot(ctx)
|
||||||
|
if usageErr != nil {
|
||||||
|
logger.Warnf("读取存储目标用量失败,跳过本次软配额校验:%v", usageErr)
|
||||||
|
storageUsage = map[uint]int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotKey := store.SnapshotKey(task.ID, recordID, startedAt)
|
||||||
|
result := &repositoryTaskResult{
|
||||||
|
fileName: filepath.Base(snapshotKey),
|
||||||
|
logicalSize: plan.LogicalSize,
|
||||||
|
storagePath: snapshotKey,
|
||||||
|
manifestJSON: string(manifestBytes),
|
||||||
|
uploadResults: make([]StorageUploadResultItem, 0, len(targetIDs)),
|
||||||
|
providers: make(map[uint]storage.StorageProvider),
|
||||||
|
}
|
||||||
|
var failures []string
|
||||||
|
for _, targetID := range targetIDs {
|
||||||
|
target, findErr := s.targets.FindByID(ctx, targetID)
|
||||||
|
targetName := fmt.Sprintf("target-%d", targetID)
|
||||||
|
if findErr == nil && target != nil {
|
||||||
|
targetName = target.Name
|
||||||
|
}
|
||||||
|
if findErr != nil || target == nil {
|
||||||
|
message := "存储目标不存在"
|
||||||
|
if findErr != nil {
|
||||||
|
message = findErr.Error()
|
||||||
|
}
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: message})
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %s", targetName, message))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider, resolveErr := s.resolveProviderForNode(ctx, targetID, task.NodeID)
|
||||||
|
if resolveErr != nil {
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: resolveErr.Error()})
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %v", targetName, resolveErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Infof("同步 CDC 仓库到存储目标:%s", targetName)
|
||||||
|
unlock := s.acquireRepositoryLock(targetID)
|
||||||
|
estimatedSize, estimateErr := store.EstimateUploadSize(ctx, provider, plan)
|
||||||
|
if estimateErr != nil {
|
||||||
|
unlock()
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: estimateErr.Error()})
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %v", targetName, estimateErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if target.QuotaBytes > 0 && storageUsage[targetID]+estimatedSize > target.QuotaBytes {
|
||||||
|
unlock()
|
||||||
|
message := fmt.Sprintf("超出存储目标配额(%d + 预计 %d > %d)", storageUsage[targetID], estimatedSize, target.QuotaBytes)
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: message})
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %s", targetName, message))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
upload, uploadErr := store.Upload(ctx, provider, plan, snapshotKey)
|
||||||
|
unlock()
|
||||||
|
if uploadErr != nil {
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()})
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: %v", targetName, uploadErr))
|
||||||
|
logger.Warnf("存储目标 %s CDC 仓库同步失败:%v", targetName, uploadErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.uploadResults = append(result.uploadResults, StorageUploadResultItem{
|
||||||
|
StorageTargetID: targetID, StorageTargetName: targetName, Status: "success",
|
||||||
|
StoragePath: upload.SnapshotKey, FileSize: upload.UploadedBytes,
|
||||||
|
})
|
||||||
|
result.providers[targetID] = provider
|
||||||
|
if result.storageTargetID == 0 {
|
||||||
|
result.storageTargetID = targetID
|
||||||
|
result.checksum = upload.Checksum
|
||||||
|
}
|
||||||
|
logger.Infof("存储目标 %s CDC 同步完成:新块 %d/%d,复用 %d bytes,实际上传 %d bytes", targetName, upload.NewChunks, upload.UniqueChunks, upload.ReusedBytes, upload.UploadedBytes)
|
||||||
|
}
|
||||||
|
if result.storageTargetID == 0 {
|
||||||
|
return nil, fmt.Errorf("所有存储目标 CDC 仓库同步均失败:%s", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
logger.Warnf("部分存储目标 CDC 仓库同步失败:%s", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
if s.dependentsResolver != nil {
|
||||||
|
go func(upstreamID uint, upstreamName string) {
|
||||||
|
dependents, resolveErr := s.dependentsResolver.TriggerDependents(context.Background(), upstreamID)
|
||||||
|
if resolveErr != nil {
|
||||||
|
logger.Warnf("解析任务 %s 的下游依赖失败:%v", upstreamName, resolveErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, dependentID := range dependents {
|
||||||
|
if _, runErr := s.RunTaskByID(context.Background(), dependentID); runErr != nil {
|
||||||
|
logger.Warnf("触发下游任务 #%d 失败(上游: %s):%v", dependentID, upstreamName, runErr)
|
||||||
|
} else {
|
||||||
|
logger.Infof("已触发下游任务 #%d(上游: %s)", dependentID, upstreamName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(task.ID, task.Name)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BackupExecutionService) acquireRepositoryLock(targetID uint) func() {
|
||||||
|
created := &sync.Mutex{}
|
||||||
|
actual, _ := s.repositoryLocks.LoadOrStore(targetID, created)
|
||||||
|
lock := actual.(*sync.Mutex)
|
||||||
|
lock.Lock()
|
||||||
|
return lock.Unlock
|
||||||
|
}
|
||||||
|
|
||||||
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
|
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
|
||||||
// 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0,
|
// 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0,
|
||||||
// 该节点上所有任务共享一个节点专属 semaphore,互相排队
|
// 该节点上所有任务共享一个节点专属 semaphore,互相排队
|
||||||
@@ -658,18 +894,33 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
backupKind := model.BackupKindFull
|
backupKind := model.BackupKindFull
|
||||||
var baseRecordID uint
|
var baseRecordID uint
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
|
var repositoryProviders map[uint]storage.StorageProvider
|
||||||
completeRecord := func() {
|
completeRecord := func() {
|
||||||
|
readyForRepositoryRetention := status == model.BackupRecordStatusSuccess
|
||||||
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
|
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
|
||||||
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
||||||
|
readyForRepositoryRetention = false
|
||||||
}
|
}
|
||||||
// 采集任务执行结果到 Prometheus(耗时 + 产出字节 + 状态计数)
|
// 采集任务执行结果到 Prometheus(耗时 + 产出字节 + 状态计数)
|
||||||
s.metrics.ObserveTaskRun(task.Type, status, time.Since(startedAt).Seconds(), fileSize)
|
s.metrics.ObserveTaskRun(task.Type, status, time.Since(startedAt).Seconds(), fileSize)
|
||||||
// 写入多目标上传结果
|
// 写入多目标上传结果
|
||||||
if len(uploadResults) > 0 {
|
if len(uploadResults) > 0 {
|
||||||
if resultsJSON, marshalErr := json.Marshal(uploadResults); marshalErr == nil {
|
resultsJSON, marshalErr := json.Marshal(uploadResults)
|
||||||
if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil {
|
if marshalErr != nil {
|
||||||
record.StorageUploadResults = string(resultsJSON)
|
logger.Warnf("序列化多目标上传结果失败:%v", marshalErr)
|
||||||
_ = s.records.Update(ctx, record)
|
readyForRepositoryRetention = false
|
||||||
|
} else if record, findErr := s.records.FindByID(ctx, recordID); findErr != nil || record == nil {
|
||||||
|
if findErr != nil {
|
||||||
|
logger.Warnf("读取备份记录以写回多目标结果失败:%v", findErr)
|
||||||
|
} else {
|
||||||
|
logger.Warnf("备份记录 #%d 不存在,无法写回多目标结果", recordID)
|
||||||
|
}
|
||||||
|
readyForRepositoryRetention = false
|
||||||
|
} else {
|
||||||
|
record.StorageUploadResults = string(resultsJSON)
|
||||||
|
if updateErr := s.records.Update(ctx, record); updateErr != nil {
|
||||||
|
logger.Warnf("写回多目标上传结果失败:%v", updateErr)
|
||||||
|
readyForRepositoryRetention = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,6 +932,36 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
record.Manifest = manifestJSON
|
record.Manifest = manifestJSON
|
||||||
if updErr := s.records.Update(ctx, record); updErr != nil {
|
if updErr := s.records.Update(ctx, record); updErr != nil {
|
||||||
logger.Warnf("写回差异链信息失败:%v", updErr)
|
logger.Warnf("写回差异链信息失败:%v", updErr)
|
||||||
|
readyForRepositoryRetention = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if findErr != nil {
|
||||||
|
logger.Warnf("读取备份记录以写回备份类型失败:%v", findErr)
|
||||||
|
} else {
|
||||||
|
logger.Warnf("备份记录 #%d 不存在,无法写回备份类型", recordID)
|
||||||
|
}
|
||||||
|
readyForRepositoryRetention = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readyForRepositoryRetention && backupKind == model.BackupKindRepository && s.retention != nil && len(repositoryProviders) > 0 {
|
||||||
|
targetIDs := make([]uint, 0, len(repositoryProviders))
|
||||||
|
for targetID := range repositoryProviders {
|
||||||
|
targetIDs = append(targetIDs, targetID)
|
||||||
|
}
|
||||||
|
sort.Slice(targetIDs, func(i, j int) bool { return targetIDs[i] < targetIDs[j] })
|
||||||
|
unlocks := make([]func(), 0, len(targetIDs))
|
||||||
|
for _, targetID := range targetIDs {
|
||||||
|
unlocks = append(unlocks, s.acquireRepositoryLock(targetID))
|
||||||
|
}
|
||||||
|
cleanupResult, cleanupErr := s.retention.CleanupProviders(ctx, task, repositoryProviders)
|
||||||
|
for index := len(unlocks) - 1; index >= 0; index-- {
|
||||||
|
unlocks[index]()
|
||||||
|
}
|
||||||
|
if cleanupErr != nil {
|
||||||
|
logger.Warnf("执行 CDC 仓库保留策略失败:%v", cleanupErr)
|
||||||
|
} else {
|
||||||
|
for _, warning := range cleanupResult.Warnings {
|
||||||
|
logger.Warnf("CDC 仓库保留策略警告:%s", warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -701,6 +982,26 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
logger.Errorf("构建任务运行时配置失败:%v", err)
|
logger.Errorf("构建任务运行时配置失败:%v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if task.Type == model.BackupTaskTypeFile && strings.EqualFold(task.BackupMode, model.BackupModeRepository) {
|
||||||
|
backupKind = model.BackupKindRepository
|
||||||
|
repositoryResult, repositoryErr := s.executeRepositoryTask(ctx, task, recordID, startedAt, spec, logger)
|
||||||
|
if repositoryErr != nil {
|
||||||
|
errMessage = repositoryErr.Error()
|
||||||
|
logger.Errorf("执行 CDC 仓库备份失败:%v", repositoryErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileName = repositoryResult.fileName
|
||||||
|
fileSize = repositoryResult.logicalSize
|
||||||
|
checksum = repositoryResult.checksum
|
||||||
|
storagePath = repositoryResult.storagePath
|
||||||
|
selectedStorageTargetID = repositoryResult.storageTargetID
|
||||||
|
uploadResults = repositoryResult.uploadResults
|
||||||
|
repositoryProviders = repositoryResult.providers
|
||||||
|
manifestJSON = repositoryResult.manifestJSON
|
||||||
|
status = model.BackupRecordStatusSuccess
|
||||||
|
logger.Infof("CDC 仓库备份执行完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
// 差异备份:解析基线全量,命中则切换为差异模式(仅本机文件任务)。
|
// 差异备份:解析基线全量,命中则切换为差异模式(仅本机文件任务)。
|
||||||
if baseID, baseManifest, ok := s.resolveDifferentialBase(ctx, task); ok {
|
if baseID, baseManifest, ok := s.resolveDifferentialBase(ctx, task); ok {
|
||||||
spec.Differential = true
|
spec.Differential = true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -155,6 +156,108 @@ func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) {
|
||||||
|
executionService, recordService, tasks, _, records, sourceDir, storageDir := newExecutionTestServices(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
task, err := tasks.FindByID(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindByID task returned error: %v", err)
|
||||||
|
}
|
||||||
|
task.BackupMode = model.BackupModeRepository
|
||||||
|
task.Compression = "zstd"
|
||||||
|
if err := tasks.Update(ctx, task); err != nil {
|
||||||
|
t.Fatalf("Update repository task returned error: %v", err)
|
||||||
|
}
|
||||||
|
large := make([]byte, 4<<20)
|
||||||
|
for index := range large {
|
||||||
|
large[index] = byte((index * 31) % 251)
|
||||||
|
}
|
||||||
|
largePath := filepath.Join(sourceDir, "large.bin")
|
||||||
|
if err := os.WriteFile(largePath, large, 0o640); err != nil {
|
||||||
|
t.Fatalf("write large fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := executionService.RunTaskByIDSync(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first repository backup returned error: %v", err)
|
||||||
|
}
|
||||||
|
if first.Status != model.BackupRecordStatusSuccess || first.BackupKind != model.BackupKindRepository {
|
||||||
|
t.Fatalf("unexpected first repository record: %#v", first)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(first.StoragePath, ".backupx/repository/v1/snapshots/") {
|
||||||
|
t.Fatalf("unexpected repository snapshot path: %s", first.StoragePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
large[2<<20] ^= 0xff
|
||||||
|
if err := os.WriteFile(largePath, large, 0o640); err != nil {
|
||||||
|
t.Fatalf("modify large fixture: %v", err)
|
||||||
|
}
|
||||||
|
second, err := executionService.RunTaskByIDSync(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second repository backup returned error: %v", err)
|
||||||
|
}
|
||||||
|
stored, err := records.FindByID(ctx, second.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindByID repository record returned error: %v", err)
|
||||||
|
}
|
||||||
|
if stored == nil || stored.BackupKind != model.BackupKindRepository || stored.Manifest == "" {
|
||||||
|
t.Fatalf("repository metadata was not persisted: %#v", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
download, err := recordService.Download(ctx, second.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export repository snapshot returned error: %v", err)
|
||||||
|
}
|
||||||
|
exported, readErr := io.ReadAll(download.Reader)
|
||||||
|
closeErr := download.Reader.Close()
|
||||||
|
if readErr != nil || closeErr != nil {
|
||||||
|
t.Fatalf("read repository export: read=%v close=%v", readErr, closeErr)
|
||||||
|
}
|
||||||
|
if len(exported) == 0 || !strings.HasSuffix(download.FileName, ".tar") {
|
||||||
|
t.Fatalf("unexpected repository export: name=%s size=%d", download.FileName, len(exported))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(largePath, bytes.Repeat([]byte{0}, len(large)), 0o640); err != nil {
|
||||||
|
t.Fatalf("damage source before restore: %v", err)
|
||||||
|
}
|
||||||
|
if err := executionService.RestoreRecord(ctx, second.ID); err != nil {
|
||||||
|
t.Fatalf("restore repository record returned error: %v", err)
|
||||||
|
}
|
||||||
|
restored, err := os.ReadFile(largePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored source: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(restored, large) {
|
||||||
|
t.Fatalf("repository restore did not reproduce the source")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := recordService.Delete(ctx, first.ID); err != nil {
|
||||||
|
t.Fatalf("delete first repository record: %v", err)
|
||||||
|
}
|
||||||
|
if err := recordService.Delete(ctx, second.ID); err != nil {
|
||||||
|
t.Fatalf("delete second repository record: %v", err)
|
||||||
|
}
|
||||||
|
packRoot := filepath.Join(storageDir, filepath.FromSlash(".backupx/repository/v1/packs"))
|
||||||
|
remainingPacks := 0
|
||||||
|
if err := filepath.Walk(packRoot, func(_ string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
if os.IsNotExist(walkErr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if info != nil && !info.IsDir() {
|
||||||
|
remainingPacks++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("inspect repository packs: %v", err)
|
||||||
|
}
|
||||||
|
if remainingPacks != 0 {
|
||||||
|
t.Fatalf("repository prune left %d packs after deleting all snapshots", remainingPacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackupExecutionServiceNodePoolSelectionDoesNotPersistTaskNodeID(t *testing.T) {
|
func TestBackupExecutionServiceNodePoolSelectionDoesNotPersistTaskNodeID(t *testing.T) {
|
||||||
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
|
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ type BackupTaskUpsertInput struct {
|
|||||||
KeepWeekly int `json:"keepWeekly"`
|
KeepWeekly int `json:"keepWeekly"`
|
||||||
KeepMonthly int `json:"keepMonthly"`
|
KeepMonthly int `json:"keepMonthly"`
|
||||||
KeepYearly int `json:"keepYearly"`
|
KeepYearly int `json:"keepYearly"`
|
||||||
// BackupMode 备份模式:full(默认)/ differential(差异,仅文件类型本机任务)
|
// BackupMode 备份模式:full(默认)/ differential(差异归档)/ repository(CDC 去重仓库)
|
||||||
BackupMode string `json:"backupMode" binding:"omitempty,oneof=full differential"`
|
BackupMode string `json:"backupMode" binding:"omitempty,oneof=full differential repository"`
|
||||||
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
|
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
|
||||||
// 备份复制目标存储 ID 列表(3-2-1 规则)
|
// 备份复制目标存储 ID 列表(3-2-1 规则)
|
||||||
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
|
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
|
||||||
@@ -414,21 +414,50 @@ func (s *BackupTaskService) cleanupRemoteFiles(ctx context.Context, taskID uint)
|
|||||||
recordCount = len(records)
|
recordCount = len(records)
|
||||||
// 缓存 provider 避免同一存储目标重复创建连接
|
// 缓存 provider 避免同一存储目标重复创建连接
|
||||||
providerCache := make(map[uint]storage.StorageProvider)
|
providerCache := make(map[uint]storage.StorageProvider)
|
||||||
|
repositoryProviders := make(map[uint]storage.StorageProvider)
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
if strings.TrimSpace(record.StoragePath) == "" {
|
copies := []StorageUploadResultItem{{
|
||||||
continue
|
StorageTargetID: record.StorageTargetID,
|
||||||
|
Status: model.BackupRecordStatusSuccess,
|
||||||
|
StoragePath: record.StoragePath,
|
||||||
|
}}
|
||||||
|
if strings.TrimSpace(record.StorageUploadResults) != "" {
|
||||||
|
var storedCopies []StorageUploadResultItem
|
||||||
|
if unmarshalErr := json.Unmarshal([]byte(record.StorageUploadResults), &storedCopies); unmarshalErr == nil {
|
||||||
|
copies = storedCopies
|
||||||
|
}
|
||||||
}
|
}
|
||||||
provider, ok := providerCache[record.StorageTargetID]
|
seenTargets := make(map[uint]struct{}, len(copies))
|
||||||
if !ok {
|
for _, copy := range copies {
|
||||||
provider, err = s.resolveStorageProvider(ctx, record.StorageTargetID)
|
if !strings.EqualFold(copy.Status, model.BackupRecordStatusSuccess) || strings.TrimSpace(copy.StoragePath) == "" {
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
providerCache[record.StorageTargetID] = provider
|
if _, seen := seenTargets[copy.StorageTargetID]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenTargets[copy.StorageTargetID] = struct{}{}
|
||||||
|
provider, ok := providerCache[copy.StorageTargetID]
|
||||||
|
if !ok {
|
||||||
|
provider, err = s.resolveStorageProvider(ctx, copy.StorageTargetID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
providerCache[copy.StorageTargetID] = provider
|
||||||
|
}
|
||||||
|
if err := provider.Delete(ctx, copy.StoragePath); err == nil {
|
||||||
|
cleanedFiles++
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
repositoryProviders[copy.StorageTargetID] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := provider.Delete(ctx, record.StoragePath); err == nil {
|
}
|
||||||
cleanedFiles++
|
for _, provider := range repositoryProviders {
|
||||||
|
pruned, pruneErr := backup.NewRepositoryStore(s.cipher.Key()).Prune(ctx, provider)
|
||||||
|
if pruneErr != nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
cleanedFiles += pruned.DeletedIndexes + pruned.DeletedPacks
|
||||||
}
|
}
|
||||||
return recordCount, cleanedFiles
|
return recordCount, cleanedFiles
|
||||||
}
|
}
|
||||||
@@ -530,6 +559,17 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
|
|||||||
return apperror.BadRequest("BACKUP_TASK_DIFF_REMOTE_UNSUPPORTED", "差异备份当前仅支持本机 Master 执行,请将任务固定在本机或改用全量备份。", nil)
|
return apperror.BadRequest("BACKUP_TASK_DIFF_REMOTE_UNSUPPORTED", "差异备份当前仅支持本机 Master 执行,请将任务固定在本机或改用全量备份。", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(input.BackupMode), model.BackupModeRepository) {
|
||||||
|
if input.Type != model.BackupTaskTypeFile {
|
||||||
|
return apperror.BadRequest("BACKUP_TASK_REPOSITORY_UNSUPPORTED", "CDC 仓库模式仅支持文件目录类型任务", nil)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.NodePoolTag) != "" || (fixedNode != nil && !fixedNode.IsLocal) {
|
||||||
|
return apperror.BadRequest("BACKUP_TASK_REPOSITORY_REMOTE_UNSUPPORTED", "CDC 仓库模式当前采用单写者索引,仅支持 Master 本机执行。远程服务器备份请暂用全量模式。", nil)
|
||||||
|
}
|
||||||
|
if len(input.ReplicationTargetIDs) > 0 {
|
||||||
|
return apperror.BadRequest("BACKUP_TASK_REPOSITORY_REPLICATION_UNSUPPORTED", "CDC 仓库快照不能使用对象级复制;请直接为任务选择多个存储目标以生成完整仓库副本。", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
if input.RetentionDays < 0 {
|
if input.RetentionDays < 0 {
|
||||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
|
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
|
||||||
}
|
}
|
||||||
@@ -935,10 +975,17 @@ func decodeExtraConfig(value string) (map[string]any, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeBackupMode 归一化备份模式:仅文件类型可启用差异,其余一律全量(双保险,防绕过校验)。
|
// normalizeBackupMode 归一化备份模式:仅文件类型可启用差异或 CDC 仓库,
|
||||||
|
// 其余一律全量(双保险,防绕过校验)。
|
||||||
func normalizeBackupMode(mode, taskType string) string {
|
func normalizeBackupMode(mode, taskType string) string {
|
||||||
if strings.EqualFold(strings.TrimSpace(mode), model.BackupModeDifferential) && normalizeBackupTaskType(taskType) == model.BackupTaskTypeFile {
|
if normalizeBackupTaskType(taskType) != model.BackupTaskTypeFile {
|
||||||
|
return model.BackupModeFull
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case model.BackupModeDifferential:
|
||||||
return model.BackupModeDifferential
|
return model.BackupModeDifferential
|
||||||
|
case model.BackupModeRepository:
|
||||||
|
return model.BackupModeRepository
|
||||||
}
|
}
|
||||||
return model.BackupModeFull
|
return model.BackupModeFull
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ func (s *RestoreService) restoreArtifact(ctx context.Context, record *model.Back
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建存储客户端失败:%w", err)
|
return fmt.Errorf("创建存储客户端失败:%w", err)
|
||||||
}
|
}
|
||||||
|
if record.BackupKind == model.BackupKindRepository {
|
||||||
|
logger.Infof("读取 CDC 仓库快照:%s", record.StoragePath)
|
||||||
|
if err := backup.NewRepositoryStore(s.cipher.Key()).Restore(ctx, provider, record.StoragePath, spec, logger); err != nil {
|
||||||
|
return fmt.Errorf("恢复 CDC 仓库快照失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
recDir, err := os.MkdirTemp(parentTempDir, fmt.Sprintf("rec-%d-*", record.ID))
|
recDir, err := os.MkdirTemp(parentTempDir, fmt.Sprintf("rec-%d-*", record.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建恢复子目录失败:%w", err)
|
return fmt.Errorf("创建恢复子目录失败:%w", err)
|
||||||
@@ -368,6 +375,9 @@ func (s *RestoreService) buildRestoreChain(ctx context.Context, record *model.Ba
|
|||||||
}
|
}
|
||||||
|
|
||||||
func backupKindLabel(kind string) string {
|
func backupKindLabel(kind string) string {
|
||||||
|
if kind == model.BackupKindRepository {
|
||||||
|
return "CDC 仓库快照"
|
||||||
|
}
|
||||||
if kind == model.BackupKindDifferential {
|
if kind == model.BackupKindDifferential {
|
||||||
return "差异"
|
return "差异"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,20 @@ func (s *VerificationService) executeLocally(ctx context.Context, verID uint, ta
|
|||||||
logger.Errorf("创建存储客户端失败:%v", err)
|
logger.Errorf("创建存储客户端失败:%v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if backupRecord.BackupKind == model.BackupKindRepository {
|
||||||
|
logger.Infof("验证 CDC 仓库快照及全部引用块:%s", backupRecord.StoragePath)
|
||||||
|
report, verifyErr := backup.NewRepositoryStore(s.cipher.Key()).Verify(ctx, provider, backupRecord.StoragePath, backupRecord.Checksum)
|
||||||
|
if verifyErr != nil {
|
||||||
|
errMessage = verifyErr.Error()
|
||||||
|
summary = "CDC 仓库完整性校验失败"
|
||||||
|
logger.Errorf("验证未通过:%v", verifyErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status = model.VerificationRecordStatusSuccess
|
||||||
|
summary = fmt.Sprintf("CDC 仓库完整性校验通过:%d 个条目、%d 个唯一块、%d bytes", report.Entries, report.Chunks, report.Bytes)
|
||||||
|
logger.Infof("%s", summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
fileName := backupRecord.FileName
|
fileName := backupRecord.FileName
|
||||||
if strings.TrimSpace(fileName) == "" {
|
if strings.TrimSpace(fileName) == "" {
|
||||||
fileName = filepath.Base(backupRecord.StoragePath)
|
fileName = filepath.Base(backupRecord.StoragePath)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package rclone
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -68,13 +69,56 @@ func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadClose
|
|||||||
return reader, nil
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadRange reads one slice from an object. Most object-storage backends
|
||||||
|
// map this to a native HTTP Range request. Backends that reject ranged reads
|
||||||
|
// fall back to a full stream while preserving the same interface contract.
|
||||||
|
func (p *Provider) DownloadRange(ctx context.Context, objectKey string, offset, length int64) (io.ReadCloser, error) {
|
||||||
|
if offset < 0 || length <= 0 {
|
||||||
|
return nil, fmt.Errorf("rclone download range %s: invalid offset=%d length=%d", objectKey, offset, length)
|
||||||
|
}
|
||||||
|
obj, err := p.rfs.NewObject(ctx, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rclone find object %s: %w", objectKey, err)
|
||||||
|
}
|
||||||
|
reader, rangeErr := obj.Open(ctx, &fs.RangeOption{Start: offset, End: offset + length - 1})
|
||||||
|
if rangeErr == nil {
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
|
reader, err = obj.Open(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rclone download range %s (range: %v; fallback: %w)", objectKey, rangeErr, err)
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
if _, err := io.CopyN(io.Discard, reader, offset); err != nil {
|
||||||
|
closeErr := reader.Close()
|
||||||
|
return nil, errors.Join(fmt.Errorf("rclone seek object %s: %w", objectKey, err), closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &limitedReadCloser{Reader: io.LimitReader(reader, length), closer: reader}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type limitedReadCloser struct {
|
||||||
|
io.Reader
|
||||||
|
closer io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *limitedReadCloser) Close() error {
|
||||||
|
return r.closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// Delete 通过 rclone 删除远端对象。
|
// Delete 通过 rclone 删除远端对象。
|
||||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||||
obj, err := p.rfs.NewObject(ctx, objectKey)
|
obj, err := p.rfs.NewObject(ctx, objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrorObjectNotFound) || errors.Is(err, fs.ErrorDirNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("rclone find object %s: %w", objectKey, err)
|
return fmt.Errorf("rclone find object %s: %w", objectKey, err)
|
||||||
}
|
}
|
||||||
if err := obj.Remove(ctx); err != nil {
|
if err := obj.Remove(ctx); err != nil {
|
||||||
|
if errors.Is(err, fs.ErrorObjectNotFound) || errors.Is(err, fs.ErrorDirNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("rclone delete %s: %w", objectKey, err)
|
return fmt.Errorf("rclone delete %s: %w", objectKey, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -102,6 +146,9 @@ func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInf
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrorDirNotFound) || errors.Is(err, fs.ErrorObjectNotFound) {
|
||||||
|
return []storage.ObjectInfo{}, nil
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("rclone list %s: %w", prefix, err)
|
return nil, fmt.Errorf("rclone list %s: %w", prefix, err)
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ type StorageProvider interface {
|
|||||||
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
|
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StorageRangeDownloader is an optional capability used by packed repository
|
||||||
|
// backups. Implementations return exactly the requested byte range when the
|
||||||
|
// backend supports ranged reads and may transparently fall back to a full read.
|
||||||
|
type StorageRangeDownloader interface {
|
||||||
|
DownloadRange(ctx context.Context, objectKey string, offset, length int64) (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
type ProviderFactory interface {
|
type ProviderFactory interface {
|
||||||
Type() ProviderType
|
Type() ProviderType
|
||||||
}
|
}
|
||||||
@@ -151,4 +158,3 @@ type FTPConfig struct {
|
|||||||
type StorageDirCleaner interface {
|
type StorageDirCleaner interface {
|
||||||
RemoveEmptyDirs(ctx context.Context, prefix string) error
|
RemoveEmptyDirs(ctx context.Context, prefix string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,12 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
if (validPaths.length === 0 && !value.sourcePath.trim()) {
|
if (validPaths.length === 0 && !value.sourcePath.trim()) {
|
||||||
return '请输入至少一个源路径'
|
return '请输入至少一个源路径'
|
||||||
}
|
}
|
||||||
|
if (value.backupMode === 'repository' && (((value.nodeId ?? 0) > 0 && value.nodeId !== localNodeId) || value.nodePoolTag?.trim())) {
|
||||||
|
return 'CDC 仓库模式当前仅支持 Master 本机执行'
|
||||||
|
}
|
||||||
|
if (value.backupMode === 'repository' && value.replicationTargetIds.length > 0) {
|
||||||
|
return 'CDC 仓库模式请直接多选存储目标,不能使用对象级副本复制'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
|
if (isSQLiteBackupTask(value.type) && !value.dbPath.trim()) {
|
||||||
return '请输入 SQLite 数据库路径'
|
return '请输入 SQLite 数据库路径'
|
||||||
@@ -314,7 +320,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const nodeId = Number(value ?? 0)
|
const nodeId = Number(value ?? 0)
|
||||||
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
|
// 固定节点与节点池互斥:切到固定节点时清空 NodePoolTag
|
||||||
updateDraft(nodeId > 0 ? { nodeId, nodePoolTag: '' } : { nodeId })
|
updateDraft(nodeId > 0
|
||||||
|
? {
|
||||||
|
nodeId,
|
||||||
|
nodePoolTag: '',
|
||||||
|
backupMode: nodeId !== localNodeId && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
|
||||||
|
}
|
||||||
|
: { nodeId })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||||
@@ -327,7 +339,10 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
|
placeholder="填写标签后从节点池动态调度(与固定节点互斥)"
|
||||||
value={draft.nodePoolTag ?? ''}
|
value={draft.nodePoolTag ?? ''}
|
||||||
disabled={(draft.nodeId ?? 0) > 0}
|
disabled={(draft.nodeId ?? 0) > 0}
|
||||||
onChange={(value) => updateDraft({ nodePoolTag: value })}
|
onChange={(value) => updateDraft({
|
||||||
|
nodePoolTag: value,
|
||||||
|
backupMode: value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||||
执行节点选"本机 / 未指定"时可启用;从节点 Labels 命中此 tag 的在线节点中按当前运行任务数最少的挑选一台执行。
|
执行节点选"本机 / 未指定"时可启用;从节点 Labels 命中此 tag 的在线节点中按当前运行任务数最少的挑选一台执行。
|
||||||
@@ -600,8 +615,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
options={[
|
options={[
|
||||||
{ label: '全量备份', value: 'full' },
|
{ label: '全量备份', value: 'full' },
|
||||||
{ label: '差异备份(仅文件、本机)', value: 'differential' },
|
{ label: '差异备份(仅文件、本机)', value: 'differential' },
|
||||||
|
{ label: 'CDC 去重仓库(仅文件、本机)', value: 'repository' },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => updateDraft({ backupMode: value as BackupMode })}
|
onChange={(value) => updateDraft(value === 'repository'
|
||||||
|
? { backupMode: value as BackupMode, nodeId: 0, nodePoolTag: '', replicationTargetIds: [] }
|
||||||
|
: { backupMode: value as BackupMode })}
|
||||||
/>
|
/>
|
||||||
{draft.backupMode === 'differential' && (
|
{draft.backupMode === 'differential' && (
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
@@ -618,6 +636,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{draft.backupMode === 'repository' && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
|
||||||
|
文件按内容边界切块并写入全局分块池;相同数据跨文件、跨快照只上传一次。新块会合并为 pack,恢复时通过索引按需读取。当前版本采用单写者索引,因此固定在 Master 本机执行;需要多副本时请直接多选上方存储目标。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -736,10 +759,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
|||||||
value={draft.replicationTargetIds}
|
value={draft.replicationTargetIds}
|
||||||
placeholder="选择副本目标(不选 = 不启用复制)"
|
placeholder="选择副本目标(不选 = 不启用复制)"
|
||||||
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
|
options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))}
|
||||||
|
disabled={draft.backupMode === 'repository'}
|
||||||
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
|
onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })}
|
||||||
/>
|
/>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||||
备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。
|
{draft.backupMode === 'repository'
|
||||||
|
? 'CDC 仓库包含共享 pack 与索引,不能只复制单个快照对象;请在“存储目标”中直接多选以生成完整仓库副本。'
|
||||||
|
: '备份成功后自动镜像到副本存储。满足 3-2-1 规则:至少 2 份副本、至少 1 份异地。建议选不同 provider 的目标。'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function BackupRecordsPage() {
|
|||||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||||
{record.locked && <Tag color="orange" size="small" bordered>已锁定</Tag>}
|
{record.locked && <Tag color="orange" size="small" bordered>已锁定</Tag>}
|
||||||
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered>差异</Tag>}
|
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered>差异</Tag>}
|
||||||
|
{record.backupKind === 'repository' && <Tag color="blue" size="small" bordered>CDC</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||||
{record.checksum && (
|
{record.checksum && (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface BackupRecordSummary {
|
|||||||
startedAt: string
|
startedAt: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
locked: boolean
|
locked: boolean
|
||||||
backupKind: 'full' | 'differential'
|
backupKind: 'full' | 'differential' | 'repository'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupRecordContentEntry {
|
export interface BackupRecordContentEntry {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb'
|
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb'
|
||||||
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
||||||
export type BackupCompression = 'gzip' | 'zstd' | 'none'
|
export type BackupCompression = 'gzip' | 'zstd' | 'none'
|
||||||
export type BackupMode = 'full' | 'differential'
|
export type BackupMode = 'full' | 'differential' | 'repository'
|
||||||
|
|
||||||
export interface BackupTaskSummary {
|
export interface BackupTaskSummary {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user