Why wrap external tools when you can BE the tool? New physical backup engines: • MySQL Clone Plugin - native 8.0.17+ physical backup • Filesystem Snapshots - LVM/ZFS/Btrfs orchestration • Binlog Streaming - continuous backup with seconds RPO • Parallel Cloud Upload - stream directly to S3, skip local disk Smart engine selection automatically picks the optimal strategy based on: - MySQL version and edition - Available filesystem features - Database size - Cloud connectivity Zero external dependencies. Single binary. Enterprise capabilities. Commercial backup vendors: we need to talk.
362 lines
7.7 KiB
Go
362 lines
7.7 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// MockBackupEngine implements BackupEngine for testing
|
|
type MockBackupEngine struct {
|
|
name string
|
|
description string
|
|
available bool
|
|
availReason string
|
|
supportsRestore bool
|
|
supportsIncr bool
|
|
supportsStreaming bool
|
|
backupResult *BackupResult
|
|
backupError error
|
|
restoreError error
|
|
}
|
|
|
|
func (m *MockBackupEngine) Name() string { return m.name }
|
|
func (m *MockBackupEngine) Description() string { return m.description }
|
|
|
|
func (m *MockBackupEngine) CheckAvailability(ctx context.Context) (*AvailabilityResult, error) {
|
|
return &AvailabilityResult{
|
|
Available: m.available,
|
|
Reason: m.availReason,
|
|
}, nil
|
|
}
|
|
|
|
func (m *MockBackupEngine) Backup(ctx context.Context, opts *BackupOptions) (*BackupResult, error) {
|
|
if m.backupError != nil {
|
|
return nil, m.backupError
|
|
}
|
|
if m.backupResult != nil {
|
|
return m.backupResult, nil
|
|
}
|
|
return &BackupResult{
|
|
Engine: m.name,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
EndTime: time.Now(),
|
|
TotalSize: 1024 * 1024,
|
|
}, nil
|
|
}
|
|
|
|
func (m *MockBackupEngine) Restore(ctx context.Context, opts *RestoreOptions) error {
|
|
return m.restoreError
|
|
}
|
|
|
|
func (m *MockBackupEngine) SupportsRestore() bool { return m.supportsRestore }
|
|
func (m *MockBackupEngine) SupportsIncremental() bool { return m.supportsIncr }
|
|
func (m *MockBackupEngine) SupportsStreaming() bool { return m.supportsStreaming }
|
|
|
|
// MockStreamingEngine implements StreamingEngine
|
|
type MockStreamingEngine struct {
|
|
MockBackupEngine
|
|
backupToWriterResult *BackupResult
|
|
backupToWriterError error
|
|
}
|
|
|
|
func (m *MockStreamingEngine) BackupToWriter(ctx context.Context, w io.Writer, opts *BackupOptions) (*BackupResult, error) {
|
|
if m.backupToWriterError != nil {
|
|
return nil, m.backupToWriterError
|
|
}
|
|
if m.backupToWriterResult != nil {
|
|
return m.backupToWriterResult, nil
|
|
}
|
|
// Write some test data
|
|
w.Write([]byte("test backup data"))
|
|
return &BackupResult{
|
|
Engine: m.name,
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
EndTime: time.Now(),
|
|
TotalSize: 16,
|
|
}, nil
|
|
}
|
|
|
|
func TestRegistryRegisterAndGet(t *testing.T) {
|
|
registry := NewRegistry()
|
|
|
|
engine := &MockBackupEngine{
|
|
name: "test-engine",
|
|
description: "Test backup engine",
|
|
available: true,
|
|
}
|
|
|
|
registry.Register(engine)
|
|
|
|
got, err := registry.Get("test-engine")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected to get registered engine")
|
|
}
|
|
if got.Name() != "test-engine" {
|
|
t.Errorf("expected name 'test-engine', got %s", got.Name())
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetNonExistent(t *testing.T) {
|
|
registry := NewRegistry()
|
|
|
|
_, err := registry.Get("nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent engine")
|
|
}
|
|
}
|
|
|
|
func TestRegistryList(t *testing.T) {
|
|
registry := NewRegistry()
|
|
|
|
engine1 := &MockBackupEngine{name: "engine1"}
|
|
engine2 := &MockBackupEngine{name: "engine2"}
|
|
|
|
registry.Register(engine1)
|
|
registry.Register(engine2)
|
|
|
|
list := registry.List()
|
|
if len(list) != 2 {
|
|
t.Errorf("expected 2 engines, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
func TestRegistryRegisterDuplicate(t *testing.T) {
|
|
registry := NewRegistry()
|
|
|
|
engine1 := &MockBackupEngine{name: "test", description: "first"}
|
|
engine2 := &MockBackupEngine{name: "test", description: "second"}
|
|
|
|
registry.Register(engine1)
|
|
registry.Register(engine2) // Should replace
|
|
|
|
got, _ := registry.Get("test")
|
|
if got.Description() != "second" {
|
|
t.Error("duplicate registration should replace existing engine")
|
|
}
|
|
}
|
|
|
|
func TestBackupResult(t *testing.T) {
|
|
result := &BackupResult{
|
|
Engine: "test",
|
|
StartTime: time.Now().Add(-time.Minute),
|
|
EndTime: time.Now(),
|
|
TotalSize: 1024 * 1024 * 100, // 100 MB
|
|
BinlogFile: "mysql-bin.000001",
|
|
BinlogPos: 12345,
|
|
GTIDExecuted: "uuid:1-100",
|
|
Files: []BackupFile{
|
|
{
|
|
Path: "/backup/backup.tar.gz",
|
|
Size: 1024 * 1024 * 100,
|
|
Checksum: "sha256:abc123",
|
|
},
|
|
},
|
|
}
|
|
|
|
if result.Engine != "test" {
|
|
t.Errorf("expected engine 'test', got %s", result.Engine)
|
|
}
|
|
|
|
if len(result.Files) != 1 {
|
|
t.Errorf("expected 1 file, got %d", len(result.Files))
|
|
}
|
|
}
|
|
|
|
func TestProgress(t *testing.T) {
|
|
progress := Progress{
|
|
Stage: "copying",
|
|
Percent: 50.0,
|
|
BytesDone: 512 * 1024 * 1024,
|
|
BytesTotal: 1024 * 1024 * 1024,
|
|
}
|
|
|
|
if progress.Stage != "copying" {
|
|
t.Errorf("expected stage 'copying', got %s", progress.Stage)
|
|
}
|
|
|
|
if progress.Percent != 50.0 {
|
|
t.Errorf("expected percent 50.0, got %f", progress.Percent)
|
|
}
|
|
}
|
|
|
|
func TestAvailabilityResult(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result AvailabilityResult
|
|
}{
|
|
{
|
|
name: "available",
|
|
result: AvailabilityResult{
|
|
Available: true,
|
|
Info: map[string]string{"version": "8.0.30"},
|
|
},
|
|
},
|
|
{
|
|
name: "not available",
|
|
result: AvailabilityResult{
|
|
Available: false,
|
|
Reason: "MySQL 8.0.17+ required for clone plugin",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if !tt.result.Available && tt.result.Reason == "" {
|
|
t.Error("unavailable result should have a reason")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecoveryTarget(t *testing.T) {
|
|
now := time.Now()
|
|
|
|
tests := []struct {
|
|
name string
|
|
target RecoveryTarget
|
|
}{
|
|
{
|
|
name: "time target",
|
|
target: RecoveryTarget{
|
|
Type: "time",
|
|
Time: now,
|
|
},
|
|
},
|
|
{
|
|
name: "gtid target",
|
|
target: RecoveryTarget{
|
|
Type: "gtid",
|
|
GTID: "uuid:1-100",
|
|
},
|
|
},
|
|
{
|
|
name: "position target",
|
|
target: RecoveryTarget{
|
|
Type: "position",
|
|
File: "mysql-bin.000001",
|
|
Pos: 12345,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.target.Type == "" {
|
|
t.Error("target type should be set")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMockEngineBackup(t *testing.T) {
|
|
engine := &MockBackupEngine{
|
|
name: "mock",
|
|
available: true,
|
|
backupResult: &BackupResult{
|
|
Engine: "mock",
|
|
TotalSize: 1024,
|
|
BinlogFile: "test",
|
|
BinlogPos: 123,
|
|
},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
opts := &BackupOptions{
|
|
OutputDir: "/test",
|
|
}
|
|
|
|
result, err := engine.Backup(ctx, opts)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.Engine != "mock" {
|
|
t.Errorf("expected engine 'mock', got %s", result.Engine)
|
|
}
|
|
|
|
if result.BinlogFile != "test" {
|
|
t.Errorf("expected binlog file 'test', got %s", result.BinlogFile)
|
|
}
|
|
}
|
|
|
|
func TestMockStreamingEngine(t *testing.T) {
|
|
engine := &MockStreamingEngine{
|
|
MockBackupEngine: MockBackupEngine{
|
|
name: "mock-streaming",
|
|
supportsStreaming: true,
|
|
},
|
|
}
|
|
|
|
if !engine.SupportsStreaming() {
|
|
t.Error("expected streaming support")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var buf mockWriter
|
|
opts := &BackupOptions{}
|
|
|
|
result, err := engine.BackupToWriter(ctx, &buf, opts)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result.Engine != "mock-streaming" {
|
|
t.Errorf("expected engine 'mock-streaming', got %s", result.Engine)
|
|
}
|
|
|
|
if len(buf.data) == 0 {
|
|
t.Error("expected data to be written")
|
|
}
|
|
}
|
|
|
|
type mockWriter struct {
|
|
data []byte
|
|
}
|
|
|
|
func (m *mockWriter) Write(p []byte) (int, error) {
|
|
m.data = append(m.data, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func TestDefaultRegistry(t *testing.T) {
|
|
// DefaultRegistry should be initialized
|
|
if DefaultRegistry == nil {
|
|
t.Error("DefaultRegistry should not be nil")
|
|
}
|
|
}
|
|
|
|
// Benchmark tests
|
|
func BenchmarkRegistryGet(b *testing.B) {
|
|
registry := NewRegistry()
|
|
for i := 0; i < 10; i++ {
|
|
registry.Register(&MockBackupEngine{
|
|
name: string(rune('a' + i)),
|
|
})
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
registry.Get("e")
|
|
}
|
|
}
|
|
|
|
func BenchmarkRegistryList(b *testing.B) {
|
|
registry := NewRegistry()
|
|
for i := 0; i < 10; i++ {
|
|
registry.Register(&MockBackupEngine{
|
|
name: string(rune('a' + i)),
|
|
})
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
registry.List()
|
|
}
|
|
}
|