feat(engine): physical backup revolution - XtraBackup capabilities in pure Go

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.
This commit is contained in:
2025-12-13 21:21:17 +01:00
parent f69bfe7071
commit dbb0f6f942
27 changed files with 7559 additions and 268 deletions

View File

@@ -0,0 +1,310 @@
package binlog
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestEventTypes(t *testing.T) {
types := []string{"write", "update", "delete", "query", "gtid", "rotate", "format"}
for _, eventType := range types {
t.Run(eventType, func(t *testing.T) {
event := &Event{Type: eventType}
if event.Type != eventType {
t.Errorf("expected %s, got %s", eventType, event.Type)
}
})
}
}
func TestPosition(t *testing.T) {
pos := Position{
File: "mysql-bin.000001",
Position: 12345,
}
if pos.File != "mysql-bin.000001" {
t.Errorf("expected file mysql-bin.000001, got %s", pos.File)
}
if pos.Position != 12345 {
t.Errorf("expected position 12345, got %d", pos.Position)
}
}
func TestGTIDPosition(t *testing.T) {
pos := Position{
File: "mysql-bin.000001",
Position: 12345,
GTID: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5",
}
if pos.GTID == "" {
t.Error("expected GTID to be set")
}
}
func TestEvent(t *testing.T) {
event := &Event{
Type: "write",
Timestamp: time.Now(),
Database: "testdb",
Table: "users",
Rows: []map[string]any{
{"id": 1, "name": "test"},
},
RawData: []byte("INSERT INTO users (id, name) VALUES (1, 'test')"),
}
if event.Type != "write" {
t.Errorf("expected write, got %s", event.Type)
}
if event.Database != "testdb" {
t.Errorf("expected database testdb, got %s", event.Database)
}
if len(event.Rows) != 1 {
t.Errorf("expected 1 row, got %d", len(event.Rows))
}
}
func TestConfig(t *testing.T) {
cfg := Config{
Host: "localhost",
Port: 3306,
User: "repl",
Password: "secret",
ServerID: 99999,
Flavor: "mysql",
BatchMaxEvents: 1000,
BatchMaxBytes: 10 * 1024 * 1024,
BatchMaxWait: time.Second,
CheckpointEnabled: true,
CheckpointFile: "/var/lib/dbbackup/checkpoint",
UseGTID: true,
}
if cfg.Host != "localhost" {
t.Errorf("expected host localhost, got %s", cfg.Host)
}
if cfg.ServerID != 99999 {
t.Errorf("expected server ID 99999, got %d", cfg.ServerID)
}
if !cfg.UseGTID {
t.Error("expected GTID to be enabled")
}
}
// MockTarget implements Target for testing
type MockTarget struct {
events []*Event
healthy bool
closed bool
}
func NewMockTarget() *MockTarget {
return &MockTarget{
events: make([]*Event, 0),
healthy: true,
}
}
func (m *MockTarget) Name() string {
return "mock"
}
func (m *MockTarget) Type() string {
return "mock"
}
func (m *MockTarget) Write(ctx context.Context, events []*Event) error {
m.events = append(m.events, events...)
return nil
}
func (m *MockTarget) Flush(ctx context.Context) error {
return nil
}
func (m *MockTarget) Close() error {
m.closed = true
return nil
}
func (m *MockTarget) Healthy() bool {
return m.healthy
}
func TestMockTarget(t *testing.T) {
target := NewMockTarget()
ctx := context.Background()
events := []*Event{
{Type: "write", Database: "test", Table: "users"},
{Type: "update", Database: "test", Table: "users"},
}
err := target.Write(ctx, events)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(target.events) != 2 {
t.Errorf("expected 2 events, got %d", len(target.events))
}
if !target.Healthy() {
t.Error("expected target to be healthy")
}
target.Close()
if !target.closed {
t.Error("expected target to be closed")
}
}
func TestFileTargetWrite(t *testing.T) {
tmpDir := t.TempDir()
// FileTarget takes a directory path and creates files inside it
outputDir := filepath.Join(tmpDir, "binlog_output")
target, err := NewFileTarget(outputDir, 0)
if err != nil {
t.Fatalf("failed to create file target: %v", err)
}
defer target.Close()
ctx := context.Background()
events := []*Event{
{
Type: "write",
Timestamp: time.Now(),
Database: "test",
Table: "users",
Rows: []map[string]any{{"id": 1}},
},
}
err = target.Write(ctx, events)
if err != nil {
t.Fatalf("write error: %v", err)
}
err = target.Flush(ctx)
if err != nil {
t.Fatalf("flush error: %v", err)
}
target.Close()
// Find the generated file in the output directory
files, err := os.ReadDir(outputDir)
if err != nil {
t.Fatalf("failed to read output dir: %v", err)
}
if len(files) == 0 {
t.Fatal("expected at least one output file")
}
// Read the first file
outputPath := filepath.Join(outputDir, files[0].Name())
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
if len(data) == 0 {
t.Error("expected data in output file")
}
// Parse JSON
var event Event
err = json.Unmarshal(bytes.TrimSpace(data), &event)
if err != nil {
t.Fatalf("failed to parse JSON: %v", err)
}
if event.Database != "test" {
t.Errorf("expected database test, got %s", event.Database)
}
}
func TestCompressedFileTarget(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "binlog.jsonl.gz")
target, err := NewCompressedFileTarget(outputPath, 0)
if err != nil {
t.Fatalf("failed to create target: %v", err)
}
defer target.Close()
ctx := context.Background()
events := []*Event{
{
Type: "write",
Timestamp: time.Now(),
Database: "test",
Table: "users",
},
}
err = target.Write(ctx, events)
if err != nil {
t.Fatalf("write error: %v", err)
}
err = target.Flush(ctx)
if err != nil {
t.Fatalf("flush error: %v", err)
}
target.Close()
// Verify file exists
info, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("failed to stat output: %v", err)
}
if info.Size() == 0 {
t.Error("expected non-empty compressed file")
}
}
// Note: StreamerState doesn't have Running field in actual struct
func TestStreamerStatePosition(t *testing.T) {
state := StreamerState{
Position: Position{File: "mysql-bin.000001", Position: 12345},
}
if state.Position.File != "mysql-bin.000001" {
t.Errorf("expected file mysql-bin.000001, got %s", state.Position.File)
}
}
func BenchmarkEventMarshal(b *testing.B) {
event := &Event{
Type: "write",
Timestamp: time.Now(),
Database: "benchmark",
Table: "test",
Rows: []map[string]any{
{"id": 1, "name": "test", "value": 123.45},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
json.Marshal(event)
}
}