Files
dbbackup/internal/pitr/binlog_test.go
Alexander Renz f69bfe7071 feat: Add enterprise DBA features for production reliability
New features implemented:

1. Backup Catalog (internal/catalog/)
   - SQLite-based backup tracking
   - Gap detection and RPO monitoring
   - Search and statistics
   - Filesystem sync

2. DR Drill Testing (internal/drill/)
   - Automated restore testing in Docker containers
   - Database validation with custom queries
   - Catalog integration for drill-tested status

3. Smart Notifications (internal/notify/)
   - Event batching with configurable intervals
   - Time-based escalation policies
   - HTML/text/Slack templates

4. Compliance Reports (internal/report/)
   - SOC2, GDPR, HIPAA, PCI-DSS, ISO27001 frameworks
   - Evidence collection from catalog
   - JSON, Markdown, HTML output formats

5. RTO/RPO Calculator (internal/rto/)
   - Recovery objective analysis
   - RTO breakdown by phase
   - Recommendations for improvement

6. Replica-Aware Backup (internal/replica/)
   - Topology detection for PostgreSQL/MySQL
   - Automatic replica selection
   - Configurable selection strategies

7. Parallel Table Backup (internal/parallel/)
   - Concurrent table dumps
   - Worker pool with progress tracking
   - Large table optimization

8. MySQL/MariaDB PITR (internal/pitr/)
   - Binary log parsing and replay
   - Point-in-time recovery support
   - Transaction filtering

CLI commands added: catalog, drill, report, rto

All changes support the goal: reliable 3 AM database recovery.
2025-12-13 20:28:55 +01:00

586 lines
13 KiB
Go

package pitr
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBinlogPosition_String(t *testing.T) {
tests := []struct {
name string
position BinlogPosition
expected string
}{
{
name: "basic position",
position: BinlogPosition{
File: "mysql-bin.000042",
Position: 1234,
},
expected: "mysql-bin.000042:1234",
},
{
name: "with GTID",
position: BinlogPosition{
File: "mysql-bin.000042",
Position: 1234,
GTID: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5",
},
expected: "mysql-bin.000042:1234 (GTID: 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5)",
},
{
name: "MariaDB GTID",
position: BinlogPosition{
File: "mariadb-bin.000010",
Position: 500,
GTID: "0-1-100",
},
expected: "mariadb-bin.000010:500 (GTID: 0-1-100)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.position.String()
if result != tt.expected {
t.Errorf("got %q, want %q", result, tt.expected)
}
})
}
}
func TestBinlogPosition_IsZero(t *testing.T) {
tests := []struct {
name string
position BinlogPosition
expected bool
}{
{
name: "empty position",
position: BinlogPosition{},
expected: true,
},
{
name: "has file",
position: BinlogPosition{
File: "mysql-bin.000001",
},
expected: false,
},
{
name: "has position only",
position: BinlogPosition{
Position: 100,
},
expected: false,
},
{
name: "has GTID only",
position: BinlogPosition{
GTID: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.position.IsZero()
if result != tt.expected {
t.Errorf("got %v, want %v", result, tt.expected)
}
})
}
}
func TestBinlogPosition_Compare(t *testing.T) {
tests := []struct {
name string
a *BinlogPosition
b *BinlogPosition
expected int
}{
{
name: "equal positions",
a: &BinlogPosition{
File: "mysql-bin.000010",
Position: 1000,
},
b: &BinlogPosition{
File: "mysql-bin.000010",
Position: 1000,
},
expected: 0,
},
{
name: "a before b - same file",
a: &BinlogPosition{
File: "mysql-bin.000010",
Position: 100,
},
b: &BinlogPosition{
File: "mysql-bin.000010",
Position: 200,
},
expected: -1,
},
{
name: "a after b - same file",
a: &BinlogPosition{
File: "mysql-bin.000010",
Position: 300,
},
b: &BinlogPosition{
File: "mysql-bin.000010",
Position: 200,
},
expected: 1,
},
{
name: "a before b - different files",
a: &BinlogPosition{
File: "mysql-bin.000009",
Position: 9999,
},
b: &BinlogPosition{
File: "mysql-bin.000010",
Position: 100,
},
expected: -1,
},
{
name: "a after b - different files",
a: &BinlogPosition{
File: "mysql-bin.000011",
Position: 100,
},
b: &BinlogPosition{
File: "mysql-bin.000010",
Position: 9999,
},
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.a.Compare(tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
func TestParseBinlogPosition(t *testing.T) {
tests := []struct {
name string
input string
expected *BinlogPosition
expectError bool
}{
{
name: "basic position",
input: "mysql-bin.000042:1234",
expected: &BinlogPosition{
File: "mysql-bin.000042",
Position: 1234,
},
expectError: false,
},
{
name: "with GTID",
input: "mysql-bin.000042:1234:3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5",
expected: &BinlogPosition{
File: "mysql-bin.000042",
Position: 1234,
GTID: "3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5",
},
expectError: false,
},
{
name: "invalid format",
input: "invalid",
expected: nil,
expectError: true,
},
{
name: "invalid position",
input: "mysql-bin.000042:notanumber",
expected: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseBinlogPosition(tt.input)
if tt.expectError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if result.File != tt.expected.File {
t.Errorf("File: got %q, want %q", result.File, tt.expected.File)
}
if result.Position != tt.expected.Position {
t.Errorf("Position: got %d, want %d", result.Position, tt.expected.Position)
}
if result.GTID != tt.expected.GTID {
t.Errorf("GTID: got %q, want %q", result.GTID, tt.expected.GTID)
}
})
}
}
func TestExtractBinlogNumber(t *testing.T) {
tests := []struct {
name string
filename string
expected int
}{
{"mysql binlog", "mysql-bin.000042", 42},
{"mariadb binlog", "mariadb-bin.000100", 100},
{"first binlog", "mysql-bin.000001", 1},
{"large number", "mysql-bin.999999", 999999},
{"no number", "mysql-bin", 0},
{"invalid format", "binlog", 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractBinlogNumber(tt.filename)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
func TestCompareBinlogFiles(t *testing.T) {
tests := []struct {
name string
a string
b string
expected int
}{
{"equal", "mysql-bin.000010", "mysql-bin.000010", 0},
{"a < b", "mysql-bin.000009", "mysql-bin.000010", -1},
{"a > b", "mysql-bin.000011", "mysql-bin.000010", 1},
{"large difference", "mysql-bin.000001", "mysql-bin.000100", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := compareBinlogFiles(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
func TestValidateBinlogChain(t *testing.T) {
ctx := context.Background()
bm := &BinlogManager{}
tests := []struct {
name string
binlogs []BinlogFile
expectValid bool
expectGaps int
expectWarnings bool
}{
{
name: "empty chain",
binlogs: []BinlogFile{},
expectValid: true,
expectGaps: 0,
},
{
name: "continuous chain",
binlogs: []BinlogFile{
{Name: "mysql-bin.000001", ServerID: 1},
{Name: "mysql-bin.000002", ServerID: 1},
{Name: "mysql-bin.000003", ServerID: 1},
},
expectValid: true,
expectGaps: 0,
},
{
name: "chain with gap",
binlogs: []BinlogFile{
{Name: "mysql-bin.000001", ServerID: 1},
{Name: "mysql-bin.000003", ServerID: 1}, // 000002 missing
{Name: "mysql-bin.000004", ServerID: 1},
},
expectValid: false,
expectGaps: 1,
},
{
name: "chain with multiple gaps",
binlogs: []BinlogFile{
{Name: "mysql-bin.000001", ServerID: 1},
{Name: "mysql-bin.000005", ServerID: 1}, // 000002-000004 missing
{Name: "mysql-bin.000010", ServerID: 1}, // 000006-000009 missing
},
expectValid: false,
expectGaps: 2,
},
{
name: "server_id change warning",
binlogs: []BinlogFile{
{Name: "mysql-bin.000001", ServerID: 1},
{Name: "mysql-bin.000002", ServerID: 2}, // Server ID changed
{Name: "mysql-bin.000003", ServerID: 2},
},
expectValid: true,
expectGaps: 0,
expectWarnings: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := bm.ValidateBinlogChain(ctx, tt.binlogs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Valid != tt.expectValid {
t.Errorf("Valid: got %v, want %v", result.Valid, tt.expectValid)
}
if len(result.Gaps) != tt.expectGaps {
t.Errorf("Gaps: got %d, want %d", len(result.Gaps), tt.expectGaps)
}
if tt.expectWarnings && len(result.Warnings) == 0 {
t.Error("expected warnings, got none")
}
})
}
}
func TestFindBinlogsInRange(t *testing.T) {
ctx := context.Background()
bm := &BinlogManager{}
now := time.Now()
hour := time.Hour
binlogs := []BinlogFile{
{
Name: "mysql-bin.000001",
StartTime: now.Add(-5 * hour),
EndTime: now.Add(-4 * hour),
},
{
Name: "mysql-bin.000002",
StartTime: now.Add(-4 * hour),
EndTime: now.Add(-3 * hour),
},
{
Name: "mysql-bin.000003",
StartTime: now.Add(-3 * hour),
EndTime: now.Add(-2 * hour),
},
{
Name: "mysql-bin.000004",
StartTime: now.Add(-2 * hour),
EndTime: now.Add(-1 * hour),
},
{
Name: "mysql-bin.000005",
StartTime: now.Add(-1 * hour),
EndTime: now,
},
}
tests := []struct {
name string
start time.Time
end time.Time
expected int
}{
{
name: "all binlogs",
start: now.Add(-6 * hour),
end: now.Add(1 * hour),
expected: 5,
},
{
name: "middle range",
start: now.Add(-4 * hour),
end: now.Add(-2 * hour),
expected: 4, // binlogs 1-4 overlap (1 ends at -4h, 4 starts at -2h)
},
{
name: "last two",
start: now.Add(-2 * hour),
end: now,
expected: 3, // binlogs 3-5 overlap (3 ends at -2h, 5 ends at now)
},
{
name: "exact match one binlog",
start: now.Add(-3 * hour),
end: now.Add(-2 * hour),
expected: 3, // binlogs 2,3,4 overlap with this range
},
{
name: "no overlap - before",
start: now.Add(-10 * hour),
end: now.Add(-6 * hour),
expected: 0,
},
{
name: "no overlap - after",
start: now.Add(1 * hour),
end: now.Add(2 * hour),
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := bm.FindBinlogsInRange(ctx, binlogs, tt.start, tt.end)
if len(result) != tt.expected {
t.Errorf("got %d binlogs, want %d", len(result), tt.expected)
}
})
}
}
func TestBinlogArchiveInfo_Metadata(t *testing.T) {
// Test that archive metadata is properly saved and loaded
tempDir, err := os.MkdirTemp("", "binlog_test")
if err != nil {
t.Fatalf("creating temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
bm := &BinlogManager{
archiveDir: tempDir,
}
archives := []BinlogArchiveInfo{
{
OriginalFile: "mysql-bin.000001",
ArchivePath: filepath.Join(tempDir, "mysql-bin.000001.gz"),
Size: 1024,
Compressed: true,
ArchivedAt: time.Now().Add(-2 * time.Hour),
StartPos: 4,
EndPos: 1024,
StartTime: time.Now().Add(-3 * time.Hour),
EndTime: time.Now().Add(-2 * time.Hour),
},
{
OriginalFile: "mysql-bin.000002",
ArchivePath: filepath.Join(tempDir, "mysql-bin.000002.gz"),
Size: 2048,
Compressed: true,
ArchivedAt: time.Now().Add(-1 * time.Hour),
StartPos: 4,
EndPos: 2048,
StartTime: time.Now().Add(-2 * time.Hour),
EndTime: time.Now().Add(-1 * time.Hour),
},
}
// Save metadata
err = bm.SaveArchiveMetadata(archives)
if err != nil {
t.Fatalf("saving metadata: %v", err)
}
// Verify metadata file exists
metadataPath := filepath.Join(tempDir, "metadata.json")
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
t.Fatal("metadata file was not created")
}
// Load and verify
loaded := bm.loadArchiveMetadata(metadataPath)
if len(loaded) != 2 {
t.Errorf("got %d archives, want 2", len(loaded))
}
if loaded["mysql-bin.000001"].Size != 1024 {
t.Errorf("wrong size for first archive")
}
if loaded["mysql-bin.000002"].Size != 2048 {
t.Errorf("wrong size for second archive")
}
}
func TestLimitedScanner(t *testing.T) {
// Test the limited scanner used for reading dump headers
input := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n"
reader := NewLimitedScanner(strings.NewReader(input), 5)
var lines []string
for reader.Scan() {
lines = append(lines, reader.Text())
}
if len(lines) != 5 {
t.Errorf("got %d lines, want 5", len(lines))
}
}
// TestDatabaseType tests database type constants
func TestDatabaseType(t *testing.T) {
tests := []struct {
name string
dbType DatabaseType
expected string
}{
{"PostgreSQL", DatabasePostgreSQL, "postgres"},
{"MySQL", DatabaseMySQL, "mysql"},
{"MariaDB", DatabaseMariaDB, "mariadb"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.dbType) != tt.expected {
t.Errorf("got %q, want %q", tt.dbType, tt.expected)
}
})
}
}
// TestRestoreTargetType tests restore target type constants
func TestRestoreTargetType(t *testing.T) {
tests := []struct {
name string
target RestoreTargetType
expected string
}{
{"Time", RestoreTargetTime, "time"},
{"Position", RestoreTargetPosition, "position"},
{"Immediate", RestoreTargetImmediate, "immediate"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.target) != tt.expected {
t.Errorf("got %q, want %q", tt.target, tt.expected)
}
})
}
}