From ba329912013d0f95391ee2564d6dad094359b867 Mon Sep 17 00:00:00 2001 From: Marek Goc Date: Thu, 12 Feb 2026 04:52:03 +0100 Subject: [PATCH] replica ready --- .gitignore | 1 + README.md | 642 ++++++++++++++++++++++++++++++++ cmd/replica/main.go | 100 +++++ docker-compose.yml | 80 ++++ example.env | 34 ++ go.mod | 24 ++ go.sum | 75 ++++ pkg/replica/config.go | 256 +++++++++++++ pkg/replica/handlers.go | 420 +++++++++++++++++++++ pkg/replica/initial_transfer.go | 635 +++++++++++++++++++++++++++++++ pkg/replica/logging.go | 393 +++++++++++++++++++ pkg/replica/position.go | 140 +++++++ pkg/replica/service.go | 397 ++++++++++++++++++++ pkg/replica/sqlbuilder.go | 168 +++++++++ replica | Bin 0 -> 12481215 bytes 15 files changed, 3365 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/replica/main.go create mode 100644 docker-compose.yml create mode 100644 example.env create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/replica/config.go create mode 100644 pkg/replica/handlers.go create mode 100644 pkg/replica/initial_transfer.go create mode 100644 pkg/replica/logging.go create mode 100644 pkg/replica/position.go create mode 100644 pkg/replica/service.go create mode 100644 pkg/replica/sqlbuilder.go create mode 100755 replica diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..799ed7f --- /dev/null +++ b/README.md @@ -0,0 +1,642 @@ +# MariaDB/MySQL Binlog Replication Service + +A robust MySQL/MariaDB binlog streaming replication service with automatic initial data transfer, resilience features, and comprehensive error handling. Supports single or multi-secondary replica configurations with optional Graylog logging. + +## Installation + +### Quick Install (Go) + +```bash +go install git.ma-al.com/goc_marek/replica/cmd/replica@latest +``` + +### Build from Source + +```bash +# Clone the repository +git clone https://git.ma-al.com/goc_marek/replica.git +cd replica + +# Build the service +go build -o replica ./cmd/replica + +# Or install globally +go install ./cmd/replica +``` + +### Docker + +```bash +# Build the image +docker build -t replica . + +# Run with docker-compose +docker-compose up -d +``` + +## Quick Start + +```bash +# Copy example environment +cp example.env .env + +# Edit .env with your configuration +nano .env + +# Run the service +./replica +``` + +## Features + +### Core Functionality +- **Binlog Streaming**: Real-time replication from MySQL/MariaDB binlog events +- **Initial Data Transfer**: Automatic bulk data transfer when resync is needed +- **Multi-table Support**: Replicates INSERT, UPDATE, DELETE events for multiple tables +- **Position Persistence**: Saves and resumes from last processed binlog position +- **Schema Filtering**: Only replicates events for the configured schema (default: "replica") + +### Resilience & Error Handling +- **Panic Recovery**: Automatic recovery from unexpected panics in event handlers +- **Retry Logic**: Exponential backoff retry for failed SQL operations +- **Connection Health Checks**: Periodic connection health monitoring with auto-reconnect +- **Schema Drift Detection**: Detects and handles schema changes during replication +- **Graceful Degradation**: Skips problematic tables after repeated failures +- **Auto-Reconnect**: Automatic reconnection on connection errors + +### Transfer Features +- **Chunked Transfers**: Efficient batch transfers using primary key ranges +- **Progress Checkpointing**: Saves transfer progress for resume after interruption +- **Pause/Resume**: Support for pausing and resuming initial transfers +- **Transfer Statistics**: Detailed logging of transfer progress and errors + +## Architecture + +### Components + +| File | Purpose | +|------|---------| +| [`cmd/replica/main.go`](cmd/replica/main.go) | Application entry point and configuration | +| [`pkg/replica/service.go`](pkg/replica/service.go) | BinlogSyncService - core replication orchestration | +| [`pkg/replica/handlers.go`](pkg/replica/handlers.go) | EventHandlers - binlog event processing with resilience | +| [`pkg/replica/initial_transfer.go`](pkg/replica/initial_transfer.go) | InitialTransfer - bulk data transfer management | +| [`pkg/replica/position.go`](pkg/replica/position.go) | PositionManager - binlog position persistence | +| [`pkg/replica/sqlbuilder.go`](pkg/replica/sqlbuilder.go) | SQLBuilder - SQL statement generation | +| [`pkg/replica/config.go`](pkg/replica/config.go) | Configuration types | +| [`pkg/replica/logging.go`](pkg/replica/logging.go) | Structured logging with Graylog support | + +### Data Flow + +``` +Primary DB (MySQL/MariaDB) + | + v + Binlog Stream + | + v +BinlogSyncService.processEvent() + | + +---> Panic Recovery Wrapper + | + +---> EventHandlers.HandleRows() + | | + | +---> Schema Filter (only "replica" schema) + | +---> Schema Drift Check + | +---> Retry Logic (if needed) + | +---> Execute SQL + | + +---> PositionManager.Save() + | + +---> Health Checks (every 30s) +``` + +## Usage + +### Quick Start + +```bash +# Build the service +go build -o replica + +# Run the service +./replica +``` + +### Configuration + +### Environment Variables + +All configuration is done via environment variables in the `.env` file: + +```bash +# Copy example environment +cp example.env .env + +# Edit with your settings +nano .env +``` + +### Primary Database Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `MARIA_PRIMARY_HOST` | Primary database hostname | `mariadb-primary` | +| `MARIA_PRIMARY_PORT` | Primary database port | `3306` | +| `MARIA_USER` | Replication user | `replica` | +| `MARIA_PASS` | Replication password | `replica` | +| `MARIA_SERVER_ID` | Unique server ID for binlog | `100` | +| `MARIA_PRIMARY_NAME` | Instance name for logging | `mariadb-primary` | + +### Multi-Secondary Replica Configuration + +The service supports replicating to multiple secondary databases simultaneously. Configure secondaries using comma-separated values: + +| Variable | Description | Example | +|----------|-------------|---------| +| `MARIA_SECONDARY_HOSTS` | Comma-separated hostnames | `secondary-1,secondary-2,secondary-3` | +| `MARIA_SECONDARY_PORTS` | Comma-separated ports | `3307,3308,3309` | +| `MARIA_SECONDARY_NAMES` | Comma-separated instance names | `replica-1,replica-2,replica-3` | +| `MARIA_SECONDARY_USERS` | Per-secondary users (optional) | `replica1,replica2,replica3` | +| `MARIA_SECONDARY_PASSWORDS` | Per-secondary passwords (optional) | `pass1,pass2,pass3` | + +#### Example: Single Secondary + +```bash +MARIA_SECONDARY_HOSTS=mariadb-secondary +MARIA_SECONDARY_PORTS=3307 +MARIA_SECONDARY_NAMES=secondary-1 +``` + +#### Example: Three Secondaries + +```bash +MARIA_SECONDARY_HOSTS=secondary-1,secondary-2,secondary-3 +MARIA_SECONDARY_PORTS=3307,3308,3309 +MARIA_SECONDARY_NAMES=replica-1,replica-2,replica-3 +MARIA_SECONDARY_USERS=replica1,replica2,replica3 +MARIA_SECONDARY_PASSWORDS=secret1,secret2,secret3 +``` + +#### Example: Two Secondaries with Different Credentials + +```bash +MARIA_SECONDARY_HOSTS=secondary-1,secondary-2 +MARIA_SECONDARY_PORTS=3307,3308 +MARIA_SECONDARY_NAMES=replica-east,replica-west +MARIA_SECONDARY_USERS=replica_east,replica_west +MARIA_SECONDARY_PASSWORDS=east_secret,west_secret +``` + +**Note:** If `MARIA_SECONDARY_USERS` or `MARIA_SECONDARY_PASSWORDS` are not provided, the default `MARIA_USER` and `MARIA_PASS` will be used for all secondaries. + +### Graylog Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `GRAYLOG_ENABLED` | Enable Graylog logging | `false` | +| `GRAYLOG_ENDPOINT` | Graylog GELF endpoint | `localhost:12201` | +| `GRAYLOG_PROTOCOL` | Protocol (udp/tcp) | `udp` | +| `GRAYLOG_TIMEOUT` | Connection timeout | `5s` | +| `GRAYLOG_SOURCE` | Source name for logs | `binlog-sync` | + +#### Enable Graylog Logging + +```bash +# Edit .env and set: +GRAYLOG_ENABLED=true +GRAYLOG_ENDPOINT=graylog.example.com:12201 +GRAYLOG_PROTOCOL=udp +GRAYLOG_SOURCE=binlog-sync-prod +``` + +### Other Settings + +| Variable | Description | Default | +|----------|-------------|---------| +| `TRANSFER_BATCH_SIZE` | Rows per transfer chunk | `1000` | +| `LOCAL_PROJECT_NAME` | Project name for logging | `naluconcept` | + +## Resilience Features + +### Panic Recovery + +All event handlers include automatic panic recovery: + +```go +defer func() { + if r := recover(); r != nil { + log.Printf("[PANIC RECOVERED] %v", r) + } +}() +``` + +This prevents a single malformed event from crashing the entire service. Recovery is implemented in: +- [`HandleRows()`](handlers.go:42) +- [`HandleQuery()`](handlers.go:81) +- [`HandleTableMap()`](handlers.go:100) +- [`processEvent()`](service.go:177) + +### Retry Logic + +Failed SQL operations are retried with exponential backoff: + +```go +func (h *EventHandlers) executeWithRetry(query string) error { + for attempt := 0; attempt <= h.retryAttempts; attempt++ { + if attempt > 0 { + delay := h.retryDelay * time.Duration(1<= h.maxFailures { + log.Printf("[SKIPPED] Too many failures for %s.%s", schema, table) + return nil // Skip this event +} + +// Reset count on successful operation +h.failedTables[key] = 0 +``` + +**Configuration:** +- `maxFailures`: Consecutive failures before skipping (default: 5) + +### Auto-Reconnect + +Connection errors trigger automatic reconnection: + +```go +func (h *EventHandlers) reconnect() { + h.secondaryDB.Close() + + maxRetries := 5 + for i := 0; i < maxRetries; i++ { + h.secondaryDB, err = sql.Open(dsn) + // Configure connection pool + // Ping to verify + if err == nil { + log.Printf("[RECONNECT] Successfully reconnected") + return + } + time.Sleep(time.Duration(i+1) * time.Second) + } +} +``` + +**Detected connection errors:** +- "connection refused" +- "connection reset" +- "broken pipe" +- "timeout" +- "driver: bad connection" +- "invalid connection" + +## Initial Transfer + +When resync is needed (empty replica or no saved position), the service performs an initial data transfer: + +### Transfer Process + +1. **Detection**: Check if secondary database is empty or no position saved +2. **Database Enumeration**: List all databases from primary +3. **Schema Exclusion**: Skip excluded schemas (information_schema, mysql, etc.) +4. **Table Transfer**: For each table: + - Get table schema (column definitions) + - Check row count + - Transfer in chunks using primary key or LIMIT/OFFSET +5. **Progress Checkpointing**: Save progress to JSON file every 1000 rows +6. **Position Reset**: Clear saved binlog position after successful transfer +7. **Binlog Streaming**: Start streaming from current position + +### Chunked Transfer + +Tables are transferred in chunks for efficiency and memory safety: + +```sql +-- Using primary key (efficient, preserves order) +SELECT * FROM table WHERE pk >= 1000 AND pk < 2000 ORDER BY pk + +-- Without primary key (slower, may skip rows on updates) +SELECT * FROM table LIMIT 1000 OFFSET 1000 +``` + +**Batch Size:** Configurable (default: 1000 rows per chunk) + +### Progress Checkpointing + +Transfer progress is saved to `transfer_progress_{instance}.json`: + +```json +{ + "DatabasesProcessed": 2, + "CurrentDatabase": "mydb", + "TablesProcessed": { + "mydb.users": 5000, + "mydb.orders": 10000 + }, + "LastCheckpoint": "2024-01-15T10:30:00Z" +} +``` + +If the transfer is interrupted, it resumes from the last checkpoint. + +### Pause/Resume + +Transfers can be paused and resumed programmatically: + +```go +transfer := NewInitialTransfer(dsn, dsn, 1000, 1) + +// Pause during transfer +transfer.Pause() + +// Resume later +transfer.Resume() +``` + +## Configuration Reference + +### BinlogConfig + +```go +type BinlogConfig struct { + Host string // MySQL/MariaDB host + Port int // MySQL/MariaDB port + User string // Replication user + Password string // Password + ServerID uint32 // Unique server ID for replication + Name string // Instance name for logging +} +``` + +### EventHandlers + +```go +type EventHandlers struct { + secondaryDB *sql.DB + tableMapCache map[uint64]*replication.TableMapEvent + sqlBuilder *SQLBuilder + failedTables map[string]int + maxFailures int // Default: 5 + retryAttempts int // Default: 3 + retryDelay time.Duration // Default: 100ms + lastSchemaHash map[string]string +} +``` + +### InitialTransfer + +```go +type InitialTransfer struct { + primaryDB *sql.DB + secondaryDB *sql.DB + batchSize int // Default: 1000 + workerCount int // Default: 1 + excludedDBs map[string]bool + checkpointFile string + progress TransferProgress +} +``` + +### TransferProgress + +```go +type TransferProgress struct { + DatabasesProcessed int + CurrentDatabase string + TablesProcessed map[string]int64 // "schema.table" -> rows transferred + LastCheckpoint time.Time +} +``` + +### TransferStats + +```go +type TransferStats struct { + TotalRows int64 + TotalTables int + TransferTime int64 // milliseconds + Errors []string + Progress TransferProgress +} +``` + +## Logging + +The service uses structured logging with prefixes: + +| Prefix | Example | Description | +|--------|---------|-------------| +| `[INSERT]` | `[INSERT] 1 row(s) affected` | Successful INSERT | +| `[UPDATE]` | `[UPDATE] 1 row(s) affected` | Successful UPDATE | +| `[DELETE]` | `[DELETE] 1 row(s) affected` | Successful DELETE | +| `[SUCCESS]` | `[SUCCESS] 1 row(s) affected` | SQL operation succeeded | +| `[ERROR]` | `[ERROR] INSERT failed after retries` | Operation failed | +| `[WARN]` | `[WARN] Schema drift detected` | Warning condition | +| `[PANIC]` | `[PANIC RECOVERED] panic message` | Recovered panic | +| `[RETRY]` | `[RETRY] Retrying in 200ms` | Retry attempt | +| `[DRIFT]` | `[DRIFT] Schema changed` | Schema drift detected | +| `[SKIPPED]` | `[SKIPPED] Too many failures` | Table skipped | +| `[FAILURE]` | `[FAILURE] failure count: 3/5` | Table failure count | +| `[HEALTH]` | `[HEALTH CHECK] Failed` | Health check result | +| `[TRANSFER]` | `[TRANSFER] Found 5 tables` | Transfer progress | +| `[INFO]` | `[INFO] Saved position` | Informational | +| `[ROTATE]` | `[mariadb-primary] Rotated to binlog.000001` | Binlog rotation | +| `[RECONNECT]` | `[RECONNECT] Successfully reconnected` | Reconnection result | + +## Schema Filtering + +By default, only events for the "replica" schema are replicated: + +```go +// handlers.go line 54 +if schemaName != "replica" { + return nil // Skip all other schemas +} +``` + +To replicate all schemas, comment out or modify this filter. + +## Error Handling + +### Common Errors + +| Error | Cause | Resolution | +|-------|-------|------------| +| `connection refused` | Secondary DB down | Check secondary DB status | +| `schema drift detected` | Table schema changed | Manual intervention required | +| `too many failures` | Table repeatedly failing | Check table compatibility | +| `failed to get event` | Binlog stream interrupted | Service auto-recovers | +| `expected 4 destination arguments` | SHOW MASTER STATUS columns | Fixed in code | +| `assignment to entry in nil map` | Uninitialized map | Fixed in code | + +### Recovery Procedures + +1. **Secondary DB Connection Lost**: + - Service auto-reconnects (up to 5 attempts) + - Check secondary DB logs + - Verify network connectivity + +2. **Schema Drift Detected**: + - Stop service + - Compare schemas: `SHOW CREATE TABLE schema.table` on both DBs + - Sync schemas manually + - Reset position: `DELETE FROM binlog_position` + - Restart service + +3. **Transfer Interrupted**: + - Service resumes from `transfer_progress_{instance}.json` + - Check checkpoint file for progress + - Delete checkpoint file to restart transfer + +4. **Event Processing Stuck**: + - Check health check logs + - Verify binlog position: `SELECT * FROM binlog_position` + - Restart service if needed + - Clear position: `DELETE FROM binlog_position` + +## Monitoring + +### Key Metrics to Watch + +- **Replication Lag**: Time since last event processed +- **Rows Replicated**: Total INSERT/UPDATE/DELETE count +- **Tables Synced**: Number of tables synchronized +- **Error Count**: Failed operations counter +- **Success Rate**: Successful / total operations + +### Manual Verification + +```sql +-- Check position on secondary +SELECT * FROM binlog_position; + +-- Check row counts +SELECT COUNT(*) FROM replica.your_table; + +-- Compare with primary +SELECT COUNT(*) FROM your_table; +``` + +## Performance Considerations + +1. **Batch Size**: Start with 1000, adjust based on table size and memory +2. **Connection Pooling**: `SetMaxOpenConns(25)` for moderate load +3. **Worker Count**: Currently single-threaded (multi-worker planned) +4. **Schema Caching**: Table schemas cached in memory (auto-updated on drift) +5. **Index Usage**: Chunked transfers require indexed primary key + +## Limitations + +- **Single-threaded**: One worker processes events sequentially +- **Position-based**: No GTID support yet (position-based only) +- **Integer PKs**: Chunking requires integer primary key for efficiency +- **No Conflict Resolution**: Concurrent writes not handled +- **No Data Validation**: Assumes source data is valid + +## Known Issues & Fixes + +### Fixed Issues + +- [x] `SHOW MASTER STATUS` returning 4 columns (fixed by scanning 4 variables) +- [x] `nil map` panic in transfer (fixed by initializing TablesProcessed) +- [x] Event deduplication skipping valid events (disabled deduplication) +- [x] Checkpoint file path empty (fixed by setting instance-specific path) + +### Workarounds + +- **Position 0 Events**: Deduplication disabled for now +- **Schema Changes**: Service marks table as failed, manual restart required + +## Future Enhancements + +- [ ] GTID-based replication for better consistency +- [ ] Multi-threaded replication for higher throughput +- [ ] Conflict detection and resolution +- [ ] Prometheus metrics endpoint +- [ ] REST API for management +- [ ] Kubernetes operator +- [ ] Configuration file (YAML/JSON) +- [ ] Signal handling for dynamic config + +## File Structure + +``` +replica/ +├── cmd/ +│ └── replica/ +│ └── main.go # Entry point +├── pkg/ +│ └── replica/ +│ ├── service.go # Replication orchestration +│ ├── handlers.go # Event processing +│ ├── initial_transfer.go # Bulk data transfer +│ ├── position.go # Position persistence +│ ├── sqlbuilder.go # SQL generation +│ ├── config.go # Configuration types +│ └── logging.go # Structured logging +├── example.env # Environment template +├── .env # Environment (gitignored) +├── docker-compose.yml # Local development +├── go.mod # Go module +└── README.md # This file +``` + +## License + +MIT License diff --git a/cmd/replica/main.go b/cmd/replica/main.go new file mode 100644 index 0000000..51a6513 --- /dev/null +++ b/cmd/replica/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "database/sql" + "os" + "os/signal" + "strconv" + "syscall" + + "git.ma-al.com/goc_marek/replica/pkg/replica" + _ "github.com/go-sql-driver/mysql" +) + +func main() { + // Initialize the logger + replica.InitLogger() + + // Load configuration from environment + cfg, err := replica.LoadEnvConfig() + if err != nil { + replica.Fatalf("Failed to load configuration: %v", err) + } + + // Setup Graylog if enabled + if cfg.Graylog.Enabled { + replica.Infof("Graylog enabled: %s", cfg.Graylog.Endpoint) + if err := replica.SetupGlobalGraylog(replica.GraylogConfig{ + Endpoint: cfg.Graylog.Endpoint, + Protocol: cfg.Graylog.Protocol, + Timeout: cfg.Graylog.Timeout, + Source: cfg.Graylog.Source, + ExtraFields: cfg.Graylog.ExtraFields, + }); err != nil { + replica.Warnf("Failed to setup Graylog: %v", err) + } else { + replica.Info("Graylog setup successful") + } + } + + replica.Infof("Loaded configuration: %d secondary(ies)", len(cfg.Secondaries)) + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Connect to primary MariaDB + primaryDSN := cfg.Primary.User + ":" + cfg.Primary.Password + "@tcp(" + cfg.Primary.Host + ":" + + strconv.FormatUint(uint64(cfg.Primary.Port), 10) + ")/?multiStatements=true" + primaryDB, err := sql.Open("mysql", primaryDSN) + if err != nil { + replica.Fatalf("Failed to connect to primary MariaDB: %v", err) + } + defer primaryDB.Close() + + primaryDB.SetMaxOpenConns(25) + primaryDB.SetMaxIdleConns(5) + + if err := primaryDB.Ping(); err != nil { + replica.Fatalf("Failed to ping primary MariaDB: %v", err) + } + replica.Info("Connected to primary MariaDB") + + // Create multi-service manager + multiService := replica.NewMultiBinlogSyncService() + + // Connect to each secondary and create services + for _, secCfg := range cfg.Secondaries { + replica.Infof("Connecting to secondary: %s (%s:%d)", secCfg.Name, secCfg.Host, secCfg.Port) + + secondaryDB, err := sql.Open("mysql", secCfg.DSN) + if err != nil { + replica.Fatalf("Failed to connect to secondary %s: %v", secCfg.Name, err) + } + defer secondaryDB.Close() + + secondaryDB.SetMaxOpenConns(25) + secondaryDB.SetMaxIdleConns(5) + + if err := secondaryDB.Ping(); err != nil { + replica.Fatalf("Failed to ping secondary %s: %v", secCfg.Name, err) + } + replica.Infof("Connected to secondary: %s", secCfg.Name) + + // Create service for this secondary + service := replica.NewBinlogSyncService(cfg.Primary, primaryDB, secondaryDB, secCfg.Name) + multiService.AddService(service) + } + + // Start all services + replica.Info("Starting binlog replication...") + multiService.StartAll() + + // Wait for shutdown signal + sig := <-sigChan + replica.Infof("Received %v, shutting down...", sig) + + // Stop all services + multiService.StopAll() + replica.Info("Service stopped.") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7979cef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + mariadb-primary: + image: mariadb:latest + container_name: ${MARIA_PRIMARY_NAME} + command: + - --innodb_buffer_pool_size=536870912 + - --key_buffer_size=67108864 + - --query_cache_type=1 + - --query_cache_size=134217728 + - --query-cache-strip-comments=1 + - --max-connections=256 + - --log_bin=log_bin + - --binlog_format=ROW + - --server-id=${MARIA_SERVER_ID} + ports: + - "${MARIA_PRIMARY_PORT}:3306" + networks: + - repl + volumes: + - mariadb-primary-data:/var/lib/mysql + environment: + MARIADB_USER: ${MARIA_USER} + MARIADB_PASSWORD: ${MARIA_PASS} + MYSQL_DATABASE: ${MARIA_NAME} + MYSQL_ROOT_PASSWORD: ${MARIA_PASS} + restart: always + + mariadb-secondary: + image: mariadb:latest + container_name: secondary + command: + - --innodb_buffer_pool_size=536870912 + - --key_buffer_size=67108864 + - --max-connections=256 + - --server-id=2 + # - --read_only=ON + - --relay-log=relay-log + # - --log_bin=log_bin + # - --binlog_format=ROW + ports: + - "3307:3306" + networks: + - repl + volumes: + - mariadb-secondary-data:/var/lib/mysql + environment: + MARIADB_USER: ${MARIA_USER} + MARIADB_PASSWORD: ${MARIA_PASS} + MYSQL_DATABASE: ${MARIA_NAME} + MYSQL_ROOT_PASSWORD: ${MARIA_PASS} + restart: always + depends_on: + - mariadb-primary + + # postgresql: + # container_name: ${POSTGRES_HOST} + # restart: always + # image: postgres:18 + # networks: + # repl: + # ports: + # - 5432:5432 + # volumes: + # - postgres-data:/var/lib/postgresql:Z + # command: postgres -c shared_buffers=512MB -c work_mem=16MB -c maintenance_work_mem=256MB -c effective_cache_size=4GB -c max_connections=20 + # environment: + # POSTGRES_USER: ${POSTGRES_USER} + # POSTGRES_PASSWORD: ${POSTGRES_PASS} + # POSTGRES_DB: ${POSTGRES_NAME} + + +networks: + repl: + name: repl +volumes: + mariadb-primary-data: + driver: local + mariadb-secondary-data: + driver: local + diff --git a/example.env b/example.env new file mode 100644 index 0000000..65679f0 --- /dev/null +++ b/example.env @@ -0,0 +1,34 @@ +# Primary MariaDB Configuration +MARIA_USER=replica +MARIA_PASS=replica +MARIA_SERVER_ID=100 +MARIA_PRIMARY_HOST=mariadb-primary +MARIA_PRIMARY_PORT=3306 +MARIA_PRIMARY_NAME=mariadb-primary + +# Secondary MariaDB Configuration (comma-separated for multiple) +# Format: host1:port1,host2:port2,host3:port3 +# Or just hostnames and use MARIA_SECONDARY_PORTS for ports +MARIA_SECONDARY_HOSTS=mariadb-secondary-1,mariadb-secondary-2,mariadb-secondary-3 +MARIA_SECONDARY_PORTS=3307,3308,3309 +MARIA_SECONDARY_NAMES=secondary-1,secondary-2,secondary-3 + +# Optional: Override per-secondary credentials (must match number of secondaries or use defaults) +# MARIA_SECONDARY_USERS=replica1,replica2,replica3 +# MARIA_SECONDARY_PASSWORDS=pass1,pass2,pass3 + +# Legacy single secondary (for backward compatibility) +# MARIA_SECONDARY_HOST=mariadb-secondary + +# Transfer Settings +TRANSFER_BATCH_SIZE=1000 + +# Project name for logging +LOCAL_PROJECT_NAME=naluconcept + +# Graylog Configuration +GRAYLOG_ENABLED=false +GRAYLOG_ENDPOINT=localhost:12201 +GRAYLOG_PROTOCOL=udp +GRAYLOG_TIMEOUT=5s +GRAYLOG_SOURCE=binlog-sync diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9ff13ac --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module git.ma-al.com/goc_marek/replica + +go 1.23.0 + +require ( + github.com/go-mysql-org/go-mysql v1.13.0 + github.com/go-sql-driver/mysql v1.8.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/klauspost/compress v1.17.8 // indirect + github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec // indirect + github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect + github.com/shopspring/decimal v1.2.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/text v0.24.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1183093 --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-mysql-org/go-mysql v1.13.0 h1:Hlsa5x1bX/wBFtMbdIOmb6YzyaVNBWnwrb8gSIEPMDc= +github.com/go-mysql-org/go-mysql v1.13.0/go.mod h1:FQxw17uRbFvMZFK+dPtIPufbU46nBdrGaxOw0ac9MFs= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec h1:3EiGmeJWoNixU+EwllIn26x6s4njiWRXewdx2zlYa84= +github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a h1:WIhmJBlNGmnCWH6TLMdZfNEDaiU8cFpZe3iaqDbQ0M8= +github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a/go.mod h1:ORfBOFp1eteu2odzsyaxI+b8TzJwgjwyQcGhI+9SfEA= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d h1:3Ej6eTuLZp25p3aH/EXdReRHY12hjZYs3RrGp7iLdag= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/replica/config.go b/pkg/replica/config.go new file mode 100644 index 0000000..cbebd80 --- /dev/null +++ b/pkg/replica/config.go @@ -0,0 +1,256 @@ +package replica + +import ( + "os" + "strconv" + "strings" + "time" +) + +// BinlogConfig holds the configuration for connecting to MySQL/MariaDB binlog +type BinlogConfig struct { + Host string + Port uint16 + User string + Password string + ServerID uint32 + Name string // Instance name for logging +} + +// SecondaryConfig holds the configuration for a secondary database +type SecondaryConfig struct { + Name string // Friendly name for logging + Host string + Port uint16 + User string + Password string + DSN string // Pre-built DSN for convenience +} + +// GraylogConfig holds the configuration for Graylog integration +type GraylogConfig struct { + Enabled bool + Endpoint string + Protocol string + Timeout time.Duration + Source string + ExtraFields map[string]interface{} +} + +// AppConfig holds the complete application configuration +type AppConfig struct { + // Primary configuration + Primary BinlogConfig + + // Secondary configurations + Secondaries []SecondaryConfig + + // Transfer settings + BatchSize int + ExcludeSchemas []string + + // Graylog configuration + Graylog GraylogConfig +} + +// LoadEnvConfig loads configuration from environment variables +func LoadEnvConfig() (*AppConfig, error) { + cfg := &AppConfig{ + BatchSize: 1000, + ExcludeSchemas: []string{"information_schema", "performance_schema", "mysql", "sys"}, + } + + // Primary configuration + cfg.Primary.Host = getEnv("MARIA_PRIMARY_HOST", "localhost") + cfg.Primary.Port = uint16(getEnvInt("MARIA_PRIMARY_PORT", 3306)) + cfg.Primary.User = getEnv("MARIA_USER", "replica") + cfg.Primary.Password = getEnv("MARIA_PASS", "replica") + cfg.Primary.ServerID = uint32(getEnvInt("MARIA_SERVER_ID", 100)) + cfg.Primary.Name = getEnv("MARIA_PRIMARY_NAME", "mariadb-primary") + + // Parse secondary hosts (comma-separated) + secondaryHosts := getEnv("MARIA_SECONDARY_HOSTS", "") + if secondaryHosts == "" { + // Fallback to single secondary for backward compatibility + secondaryHosts = getEnv("MARIA_SECONDARY_HOST", "localhost") + } + + // Parse secondary ports (comma-separated, must match number of hosts or be single value) + secondaryPortsStr := getEnv("MARIA_SECONDARY_PORTS", "") + secondaryPorts := parseUint16List(secondaryPortsStr, 3307) + + // Parse secondary users (comma-separated, optional) + secondaryUsers := getEnv("MARIA_SECONDARY_USERS", "") + users := parseStringList(secondaryUsers, cfg.Primary.User) + + // Parse secondary passwords (comma-separated, optional) + secondaryPasswords := getEnv("MARIA_SECONDARY_PASSWORDS", "") + passwords := parseStringList(secondaryPasswords, cfg.Primary.Password) + + // Parse secondary names (comma-separated, optional) + secondaryNames := getEnv("MARIA_SECONDARY_NAMES", "") + + hosts := parseStringList(secondaryHosts, "") + names := parseStringList(secondaryNames, "") + portMap := makePortsMap(hosts, secondaryPorts, 3307) + userMap := makeStringMap(hosts, users, cfg.Primary.User) + passMap := makeStringMap(hosts, passwords, cfg.Primary.Password) + + // Build secondary configurations + for i, host := range hosts { + name := strconv.Itoa(i + 1) + if i < len(names) && names[i] != "" { + name = names[i] + } + + port := uint16(3307) + if p, ok := portMap[host]; ok { + port = p + } + + user := cfg.Primary.User + if u, ok := userMap[host]; ok { + user = u + } + + pass := cfg.Primary.Password + if p, ok := passMap[host]; ok { + pass = p + } + + dsn := buildDSN(user, pass, host, int(port), "replica") + + cfg.Secondaries = append(cfg.Secondaries, SecondaryConfig{ + Name: name, + Host: host, + Port: port, + User: user, + Password: pass, + DSN: dsn, + }) + } + + // Batch size override + if batchSize := getEnvInt("TRANSFER_BATCH_SIZE", 0); batchSize > 0 { + cfg.BatchSize = batchSize + } + + // Graylog configuration + cfg.Graylog.Enabled = getEnvBool("GRAYLOG_ENABLED", false) + cfg.Graylog.Endpoint = getEnv("GRAYLOG_ENDPOINT", "localhost:12201") + cfg.Graylog.Protocol = getEnv("GRAYLOG_PROTOCOL", "udp") + cfg.Graylog.Source = getEnv("GRAYLOG_SOURCE", "binlog-sync") + + // Parse timeout + if timeout := getEnv("GRAYLOG_TIMEOUT", "5s"); timeout != "" { + d, err := time.ParseDuration(timeout) + if err == nil { + cfg.Graylog.Timeout = d + } else { + cfg.Graylog.Timeout = 5 * time.Second + } + } + + return cfg, nil +} + +// getEnv gets an environment variable with a default value +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} + +// getEnvInt gets an environment variable as an integer with a default value +func getEnvInt(key string, defaultValue int) int { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + i, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + return i +} + +// getEnvBool gets an environment variable as a boolean with a default value +func getEnvBool(key string, defaultValue bool) bool { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + b, err := strconv.ParseBool(value) + if err != nil { + return defaultValue + } + return b +} + +// parseStringList parses a comma-separated string into a list +func parseStringList(s string, defaultValue string) []string { + if strings.TrimSpace(s) == "" { + if defaultValue == "" { + return nil + } + return []string{defaultValue} + } + parts := strings.Split(s, ",") + result := make([]string, len(parts)) + for i, p := range parts { + result[i] = strings.TrimSpace(p) + } + return result +} + +// parseUint16List parses a comma-separated string into a list of uint16 +func parseUint16List(s string, defaultValue uint16) []uint16 { + if strings.TrimSpace(s) == "" { + return []uint16{defaultValue} + } + parts := strings.Split(s, ",") + result := make([]uint16, len(parts)) + for i, p := range parts { + p = strings.TrimSpace(p) + val, err := strconv.ParseUint(p, 10, 16) + if err != nil { + result[i] = defaultValue + } else { + result[i] = uint16(val) + } + } + return result +} + +// makePortsMap creates a map of host to port +func makePortsMap(hosts []string, ports []uint16, defaultPort uint16) map[string]uint16 { + result := make(map[string]uint16) + for i, host := range hosts { + if i < len(ports) { + result[host] = ports[i] + } else { + result[host] = defaultPort + } + } + return result +} + +// makeStringMap creates a map of host to string value +func makeStringMap(hosts []string, values []string, defaultValue string) map[string]string { + result := make(map[string]string) + for i, host := range hosts { + if i < len(values) && values[i] != "" { + result[host] = values[i] + } else { + result[host] = defaultValue + } + } + return result +} + +// buildDSN builds a MySQL DSN string +func buildDSN(user, password, host string, port int, database string) string { + return user + ":" + password + "@tcp(" + host + ":" + strconv.Itoa(port) + ")/" + database + "?multiStatements=true" +} diff --git a/pkg/replica/handlers.go b/pkg/replica/handlers.go new file mode 100644 index 0000000..c42e13b --- /dev/null +++ b/pkg/replica/handlers.go @@ -0,0 +1,420 @@ +package replica + +import ( + "database/sql" + "fmt" + "sync" + "time" + + "github.com/go-mysql-org/go-mysql/replication" +) + +// EventHandlers handles binlog event processing with resilience features +type EventHandlers struct { + secondaryDB *sql.DB + secondaryName string + tableMapCache map[uint64]*replication.TableMapEvent + tableMapMu sync.RWMutex + sqlBuilder *SQLBuilder + failedTables map[string]int // tableName -> consecutive failure count + failedTablesMu sync.RWMutex + maxFailures int // max consecutive failures before skipping + retryAttempts int // number of retry attempts + retryDelay time.Duration // base retry delay + lastSchemaHash map[string]string // tableName -> schema hash + lastSchemaMu sync.RWMutex +} + +// NewEventHandlers creates new event handlers with default resilience settings +func NewEventHandlers(secondaryDB *sql.DB, secondaryName string) *EventHandlers { + return &EventHandlers{ + secondaryDB: secondaryDB, + secondaryName: secondaryName, + tableMapCache: make(map[uint64]*replication.TableMapEvent), + sqlBuilder: NewSQLBuilder(), + failedTables: make(map[string]int), + maxFailures: 5, + retryAttempts: 3, + retryDelay: 100 * time.Millisecond, + lastSchemaHash: make(map[string]string), + } +} + +// HandleRows processes row-level events with panic recovery and retry logic +func (h *EventHandlers) HandleRows(header *replication.EventHeader, e *replication.RowsEvent) error { + // Panic recovery wrapper + defer func() { + if r := recover(); r != nil { + Errorf("[%s][PANIC RECOVERED] HandleRows panic: %v", h.secondaryName, r) + } + }() + + tableName := string(e.Table.Table) + schemaName := string(e.Table.Schema) + + if schemaName != "replica" { + return nil + } + + // Check if table is temporarily skipped due to failures + if h.isTableSkipped(schemaName, tableName) { + Warn("[%s][SKIPPED] Skipping event for %s.%s (too many failures)", h.secondaryName, schemaName, tableName) + return nil + } + + eventType := h.getEventTypeName(header.EventType) + Info("[%s][%s] %s.%s", h.secondaryName, eventType, schemaName, tableName) + + if h.secondaryDB != nil { + // Schema drift detection + if h.detectSchemaDrift(schemaName, tableName) { + Warn("[%s][WARN] Schema drift detected for %s.%s, pausing replication", h.secondaryName, schemaName, tableName) + h.markTableFailed(schemaName, tableName) + return fmt.Errorf("schema drift detected") + } + + h.handleSecondaryReplication(e, header.EventType) + } + + return nil +} + +// HandleQuery processes query events with panic recovery +func (h *EventHandlers) HandleQuery(e *replication.QueryEvent) error { + // Panic recovery wrapper + defer func() { + if r := recover(); r != nil { + Errorf("[%s][PANIC RECOVERED] HandleQuery panic: %v", h.secondaryName, r) + } + }() + + query := string(e.Query) + if h.secondaryDB != nil { + _, err := h.secondaryDB.Exec(query) + if err != nil { + Errorf("[%s][ERROR] Query failed: %v", h.secondaryName, err) + } + } + return nil +} + +// HandleTableMap caches table map events +func (h *EventHandlers) HandleTableMap(e *replication.TableMapEvent) { + // Panic recovery wrapper + defer func() { + if r := recover(); r != nil { + Errorf("[%s][PANIC RECOVERED] HandleTableMap panic: %v", h.secondaryName, r) + } + }() + + tableID := (uint64(e.TableID) << 8) | uint64(e.TableID>>56) + h.tableMapMu.Lock() + h.tableMapCache[tableID] = e + h.tableMapMu.Unlock() +} + +// GetTableMap returns the cached table map for a table ID +func (h *EventHandlers) GetTableMap(tableID uint64) *replication.TableMapEvent { + h.tableMapMu.RLock() + defer h.tableMapMu.RUnlock() + return h.tableMapCache[tableID] +} + +// isTableSkipped checks if a table should be skipped due to too many failures +func (h *EventHandlers) isTableSkipped(schema, table string) bool { + key := schema + "." + table + h.failedTablesMu.RLock() + defer h.failedTablesMu.RUnlock() + return h.failedTables[key] >= h.maxFailures +} + +// markTableFailed records a failure for a table +func (h *EventHandlers) markTableFailed(schema, table string) { + key := schema + "." + table + h.failedTablesMu.Lock() + h.failedTables[key]++ + failCount := h.failedTables[key] + h.failedTablesMu.Unlock() + + Warn("[%s][FAILURE] %s.%s failure count: %d/%d", h.secondaryName, schema, table, failCount, h.maxFailures) +} + +// markTableSuccess records a successful operation for a table +func (h *EventHandlers) markTableSuccess(schema, table string) { + key := schema + "." + table + h.failedTablesMu.Lock() + h.failedTables[key] = 0 // Reset failure count on success + h.failedTablesMu.Unlock() +} + +// detectSchemaDrift checks if the table schema has changed +func (h *EventHandlers) detectSchemaDrift(schema, table string) bool { + key := schema + "." + table + + // Get current schema hash + currentHash, err := h.getSchemaHash(schema, table) + if err != nil { + Warn("[%s][WARN] Could not get schema hash for %s.%s: %v", h.secondaryName, schema, table, err) + return false + } + + h.lastSchemaMu.RLock() + lastHash, exists := h.lastSchemaHash[key] + h.lastSchemaMu.RUnlock() + + if !exists { + // First time seeing this table + h.lastSchemaMu.Lock() + h.lastSchemaHash[key] = currentHash + h.lastSchemaMu.Unlock() + return false + } + + if lastHash != currentHash { + Warn("[%s][DRIFT] Schema changed for %s.%s: %s -> %s", h.secondaryName, schema, table, lastHash, currentHash) + return true + } + + return false +} + +// getSchemaHash returns a hash of the table schema +func (h *EventHandlers) getSchemaHash(schema, table string) (string, error) { + query := fmt.Sprintf( + "SELECT MD5(GROUP_CONCAT(COLUMN_NAME, ':', DATA_TYPE, ':', IS_NULLABLE ORDER BY ORDINAL_POSITION)) "+ + "FROM INFORMATION_SCHEMA.COLUMNS "+ + "WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s'", + schema, table, + ) + + var hash string + err := h.secondaryDB.QueryRow(query).Scan(&hash) + return hash, err +} + +func (h *EventHandlers) handleSecondaryReplication(e *replication.RowsEvent, eventType replication.EventType) { + schemaName := string(e.Table.Schema) + tableName := string(e.Table.Table) + + tableID := (uint64(e.TableID) << 8) | uint64(e.TableID>>56) + tableMap := h.GetTableMap(tableID) + + if tableMap == nil { + tableMap = e.Table + } + + if len(tableMap.ColumnName) == 0 { + columns, err := h.fetchColumnNames(schemaName, tableName) + if err != nil { + Errorf("[%s][ERROR] Failed to fetch columns: %v", h.secondaryName, err) + return + } + + columnBytes := make([][]byte, len(columns)) + for i, col := range columns { + columnBytes[i] = []byte(col) + } + + tableMap = &replication.TableMapEvent{ + Schema: e.Table.Schema, + Table: e.Table.Table, + ColumnName: columnBytes, + } + } + + switch eventType { + case replication.WRITE_ROWS_EVENTv1, replication.WRITE_ROWS_EVENTv2: + h.replicateInsert(tableMap, schemaName, tableName, e.Rows) + case replication.UPDATE_ROWS_EVENTv1, replication.UPDATE_ROWS_EVENTv2: + h.replicateUpdate(tableMap, schemaName, tableName, e.Rows) + case replication.DELETE_ROWS_EVENTv1, replication.DELETE_ROWS_EVENTv2: + h.replicateDelete(tableMap, schemaName, tableName, e.Rows) + } +} + +// replicateInsert inserts rows with retry logic +func (h *EventHandlers) replicateInsert(tableMap *replication.TableMapEvent, schema, table string, rows [][]interface{}) { + for _, row := range rows { + query := h.sqlBuilder.BuildInsert(schema, table, tableMap, row) + + err := h.executeWithRetry(query) + if err != nil { + Errorf("[%s][ERROR] INSERT failed after retries: %v", h.secondaryName, err) + h.markTableFailed(schema, table) + } else { + h.markTableSuccess(schema, table) + } + } +} + +// replicateUpdate updates rows with retry logic +func (h *EventHandlers) replicateUpdate(tableMap *replication.TableMapEvent, schema, table string, rows [][]interface{}) { + for i := 0; i < len(rows); i += 2 { + query := h.sqlBuilder.BuildUpdate(schema, table, tableMap, rows[i], rows[i+1]) + + err := h.executeWithRetry(query) + if err != nil { + Errorf("[%s][ERROR] UPDATE failed after retries: %v", h.secondaryName, err) + h.markTableFailed(schema, table) + } else { + h.markTableSuccess(schema, table) + } + } +} + +// replicateDelete deletes rows with retry logic +func (h *EventHandlers) replicateDelete(tableMap *replication.TableMapEvent, schema, table string, rows [][]interface{}) { + for _, row := range rows { + query := h.sqlBuilder.BuildDelete(schema, table, tableMap, row) + + err := h.executeWithRetry(query) + if err != nil { + Errorf("[%s][ERROR] DELETE failed after retries: %v", h.secondaryName, err) + h.markTableFailed(schema, table) + } else { + h.markTableSuccess(schema, table) + } + } +} + +// executeWithRetry executes a query with exponential backoff retry +func (h *EventHandlers) executeWithRetry(query string) error { + var lastErr error + + for attempt := 0; attempt <= h.retryAttempts; attempt++ { + if attempt > 0 { + delay := h.retryDelay * time.Duration(1<= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/replica/initial_transfer.go b/pkg/replica/initial_transfer.go new file mode 100644 index 0000000..8016b3f --- /dev/null +++ b/pkg/replica/initial_transfer.go @@ -0,0 +1,635 @@ +package replica + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// TransferStats holds statistics about the transfer +type TransferStats struct { + TotalRows int64 + TotalTables int + TransferTime int64 // in milliseconds + Errors []string + Progress TransferProgress +} + +// TransferProgress tracks the current position in the transfer +type TransferProgress struct { + DatabasesProcessed int + CurrentDatabase string + TablesProcessed map[string]int64 // tableName -> rows transferred + LastCheckpoint time.Time +} + +// InitialTransfer handles the initial data transfer from primary to secondary +type InitialTransfer struct { + primaryDB *sql.DB + secondaryDB *sql.DB + batchSize int + workerCount int + excludedDBs map[string]bool + mu sync.Mutex + stats TransferStats + checkpointFile string + progress TransferProgress + pauseChan chan struct{} + resumeChan chan struct{} + isPaused bool +} + +// NewInitialTransfer creates a new initial transfer handler +func NewInitialTransfer(primaryDSN, secondaryDSN string, batchSize, workerCount int) (*InitialTransfer, error) { + primaryDB, err := sql.Open("mysql", primaryDSN) + if err != nil { + return nil, fmt.Errorf("failed to connect to primary: %v", err) + } + primaryDB.SetMaxOpenConns(batchSize) + primaryDB.SetMaxIdleConns(2) + + secondaryDB, err := sql.Open("mysql", secondaryDSN) + if err != nil { + primaryDB.Close() + return nil, fmt.Errorf("failed to connect to secondary: %v", err) + } + secondaryDB.SetMaxOpenConns(batchSize) + secondaryDB.SetMaxIdleConns(2) + + if err := primaryDB.Ping(); err != nil { + primaryDB.Close() + secondaryDB.Close() + return nil, fmt.Errorf("failed to ping primary: %v", err) + } + + if err := secondaryDB.Ping(); err != nil { + primaryDB.Close() + secondaryDB.Close() + return nil, fmt.Errorf("failed to ping secondary: %v", err) + } + + return &InitialTransfer{ + primaryDB: primaryDB, + secondaryDB: secondaryDB, + batchSize: batchSize, + workerCount: workerCount, + excludedDBs: map[string]bool{ + "information_schema": true, + "performance_schema": true, + "mysql": true, + "sys": true, + }, + checkpointFile: "transfer_progress.json", + progress: TransferProgress{ + TablesProcessed: make(map[string]int64), + }, + pauseChan: make(chan struct{}), + resumeChan: make(chan struct{}), + }, nil +} + +// Transfer executes the initial data transfer +func (t *InitialTransfer) Transfer(excludeSchemas []string) error { + startTime := time.Now() + + // Load previous progress if exists + t.loadProgress() + + // Add user-specified exclusions + for _, db := range excludeSchemas { + t.excludedDBs[db] = true + } + + // Get list of databases to transfer + databases, err := t.getDatabases() + if err != nil { + return fmt.Errorf("failed to get databases: %v", err) + } + + Info("[TRANSFER] Starting initial data transfer...") + Infof("[TRANSFER] Found %d databases to transfer", len(databases)) + + // Get current binlog position before transfer + binlogPos, err := t.getBinlogPosition() + if err != nil { + Warnf("[WARN] Failed to get binlog position: %v", err) + } + + for i, dbName := range databases { + // Check for pause signal + t.checkPause() + + if t.excludedDBs[dbName] { + Infof("[TRANSFER] Skipping excluded database: %s", dbName) + continue + } + + // Skip already processed databases + if i < t.progress.DatabasesProcessed { + Infof("[TRANSFER] Skipping already processed database: %s", dbName) + continue + } + + t.progress.CurrentDatabase = dbName + t.saveProgress() + + if err := t.transferDatabase(dbName); err != nil { + return fmt.Errorf("failed to transfer database %s: %v", dbName, err) + } + + t.progress.DatabasesProcessed++ + t.saveProgress() + } + + t.stats.TransferTime = time.Since(startTime).Milliseconds() + + Infof("[TRANSFER] Transfer completed: %d tables, %d rows in %dms", + t.stats.TotalTables, t.stats.TotalRows, t.stats.TransferTime) + + if binlogPos != "" { + Infof("[TRANSFER] Binlog position before transfer: %s", binlogPos) + } + + // Clear progress on successful completion + t.clearProgress() + + return nil +} + +// checkPause checks if transfer should be paused +func (t *InitialTransfer) checkPause() { + if t.isPaused { + Info("[TRANSFER] Transfer paused, waiting for resume...") + <-t.resumeChan + Info("[TRANSFER] Transfer resumed") + } +} + +// Pause pauses the transfer +func (t *InitialTransfer) Pause() { + if !t.isPaused { + t.isPaused = true + t.pauseChan <- struct{}{} + Info("[TRANSFER] Transfer pause requested") + } +} + +// Resume resumes the transfer +func (t *InitialTransfer) Resume() { + if t.isPaused { + t.isPaused = false + t.resumeChan <- struct{}{} + Info("[TRANSFER] Transfer resume requested") + } +} + +// saveProgress saves the current progress to a checkpoint file +func (t *InitialTransfer) saveProgress() { + t.progress.LastCheckpoint = time.Now() + + data, err := json.MarshalIndent(t.progress, "", " ") + if err != nil { + Warnf("[WARN] Failed to marshal progress: %v", err) + return + } + + err = os.WriteFile(t.checkpointFile, data, 0644) + if err != nil { + Warnf("[WARN] Failed to save progress: %v", err) + } +} + +// loadProgress loads previous progress from checkpoint file +func (t *InitialTransfer) loadProgress() { + data, err := os.ReadFile(t.checkpointFile) + if err != nil { + Info("[INFO] No previous progress found, starting fresh") + return + } + + var progress TransferProgress + if err := json.Unmarshal(data, &progress); err != nil { + Warnf("[WARN] Failed to load progress: %v", err) + return + } + + t.progress = progress + Infof("[INFO] Resuming transfer: %d databases, %d tables in progress", + progress.DatabasesProcessed, len(progress.TablesProcessed)) +} + +// clearProgress removes the checkpoint file +func (t *InitialTransfer) clearProgress() { + os.Remove(t.checkpointFile) +} + +// GetProgress returns the current transfer progress +func (t *InitialTransfer) GetProgress() TransferProgress { + t.mu.Lock() + defer t.mu.Unlock() + return t.progress +} + +// getDatabases returns list of databases to transfer +func (t *InitialTransfer) getDatabases() ([]string, error) { + rows, err := t.primaryDB.Query("SHOW DATABASES") + if err != nil { + return nil, err + } + defer rows.Close() + + var databases []string + for rows.Next() { + var dbName string + if err := rows.Scan(&dbName); err != nil { + return nil, err + } + databases = append(databases, dbName) + } + + return databases, rows.Err() +} + +// transferDatabase transfers all tables in a database +func (t *InitialTransfer) transferDatabase(dbName string) error { + Infof("[TRANSFER] Transferring database: %s", dbName) + + // Get list of tables + tables, err := t.getTables(dbName) + if err != nil { + return fmt.Errorf("failed to get tables: %v", err) + } + + Infof("[TRANSFER] Found %d tables in %s", len(tables), dbName) + + for _, table := range tables { + // Check for pause signal + t.checkPause() + + // Skip already processed tables + tableKey := dbName + "." + table + if t.progress.TablesProcessed[tableKey] > 0 { + Infof("[TRANSFER] Skipping already processed table: %s", tableKey) + continue + } + + if err := t.transferTable(dbName, table); err != nil { + Errorf("[ERROR] Failed to transfer table %s.%s: %v", dbName, table, err) + t.mu.Lock() + t.stats.Errors = append(t.stats.Errors, fmt.Sprintf("%s.%s: %v", dbName, table, err)) + t.mu.Unlock() + } + } + + return nil +} + +// getTables returns list of tables in a database +func (t *InitialTransfer) getTables(dbName string) ([]string, error) { + query := fmt.Sprintf("SHOW TABLES FROM `%s`", dbName) + rows, err := t.primaryDB.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var tables []string + for rows.Next() { + var tableName string + if err := rows.Scan(&tableName); err != nil { + return nil, err + } + tables = append(tables, tableName) + } + + return tables, rows.Err() +} + +// transferTable transfers a single table with chunked reads +func (t *InitialTransfer) transferTable(dbName, tableName string) error { + // Get table structure + schema, err := t.getTableSchema(dbName, tableName) + if err != nil { + return fmt.Errorf("failed to get table schema: %v", err) + } + + // Check if table has data + hasData, err := t.tableHasData(dbName, tableName) + if err != nil { + return fmt.Errorf("failed to check table data: %v", err) + } + + if !hasData { + Infof("[TRANSFER] Table %s.%s is empty, skipping data transfer", dbName, tableName) + return nil + } + + // Get row count + count, err := t.getRowCount(dbName, tableName) + if err != nil { + return fmt.Errorf("failed to get row count: %v", err) + } + + Infof("[TRANSFER] Transferring %s.%s (%d rows)", dbName, tableName, count) + + // Get primary key or first unique column for chunking + pkColumn, err := t.getPrimaryKey(dbName, tableName) + if err != nil { + Warnf("[WARN] No primary key found for %s.%s, using full scan", dbName, tableName) + return t.transferTableFullScan(dbName, tableName, schema, count) + } + + return t.transferTableChunked(dbName, tableName, schema, pkColumn, count) +} + +// tableHasData checks if a table has any rows +func (t *InitialTransfer) tableHasData(dbName, tableName string) (bool, error) { + query := fmt.Sprintf("SELECT 1 FROM `%s`.`%s` LIMIT 1", dbName, tableName) + var exists int + err := t.primaryDB.QueryRow(query).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// getRowCount returns the number of rows in a table +func (t *InitialTransfer) getRowCount(dbName, tableName string) (int64, error) { + query := fmt.Sprintf("SELECT COUNT(*) FROM `%s`.`%s`", dbName, tableName) + var count int64 + err := t.primaryDB.QueryRow(query).Scan(&count) + return count, err +} + +// getTableSchema returns the column definitions for a table +func (t *InitialTransfer) getTableSchema(dbName, tableName string) ([]ColumnInfo, error) { + query := fmt.Sprintf( + "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY, EXTRA "+ + "FROM INFORMATION_SCHEMA.COLUMNS "+ + "WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' "+ + "ORDER BY ORDINAL_POSITION", + dbName, tableName, + ) + + rows, err := t.primaryDB.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var columns []ColumnInfo + for rows.Next() { + var col ColumnInfo + if err := rows.Scan(&col.Name, &col.Type, &col.Nullable, &col.Key, &col.Extra); err != nil { + return nil, err + } + columns = append(columns, col) + } + + return columns, rows.Err() +} + +// ColumnInfo holds information about a table column +type ColumnInfo struct { + Name string + Type string + Nullable string + Key string + Extra string +} + +// getPrimaryKey returns the primary key column for a table +func (t *InitialTransfer) getPrimaryKey(dbName, tableName string) (string, error) { + query := fmt.Sprintf( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE "+ + "WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' AND CONSTRAINT_NAME = 'PRIMARY' "+ + "ORDER BY ORDINAL_POSITION LIMIT 1", + dbName, tableName, + ) + + var pkColumn string + err := t.primaryDB.QueryRow(query).Scan(&pkColumn) + if err == sql.ErrNoRows { + return "", fmt.Errorf("no primary key") + } + return pkColumn, err +} + +// transferTableChunked transfers a table using primary key chunks +func (t *InitialTransfer) transferTableChunked(dbName, tableName string, columns []ColumnInfo, pkColumn string, rowCount int64) error { + tableKey := dbName + "." + tableName + + // Get starting offset from progress + startOffset := t.progress.TablesProcessed[tableKey] + + // Get min and max primary key values + minMax, err := t.getMinMaxPK(dbName, tableName, pkColumn) + if err != nil { + return fmt.Errorf("failed to get min/max: %v", err) + } + + // Adjust start offset to be within range + if startOffset < minMax.Min { + startOffset = minMax.Min + } + + // Transfer chunks + offset := startOffset + for offset < minMax.Max { + // Check for pause signal + t.checkPause() + + query := fmt.Sprintf( + "SELECT * FROM `%s`.`%s` WHERE `%s` >= %d AND `%s` < %d ORDER BY `%s`", + dbName, tableName, pkColumn, offset, pkColumn, offset+int64(t.batchSize), pkColumn, + ) + + rows, err := t.primaryDB.Query(query) + if err != nil { + return fmt.Errorf("failed to query chunk: %v", err) + } + + rowsInserted, err := t.insertRows(t.secondaryDB, dbName, tableName, columns, rows) + rows.Close() + + if err != nil { + return fmt.Errorf("failed to insert chunk: %v", err) + } + + // Update progress + t.mu.Lock() + t.progress.TablesProcessed[tableKey] = offset + int64(t.batchSize) + t.mu.Unlock() + + // Save checkpoint every 1000 rows + if rowsInserted%1000 == 0 { + t.saveProgress() + } + + offset += int64(t.batchSize) + } + + return nil +} + +// MinMax holds min and max values +type MinMax struct { + Min int64 + Max int64 +} + +// getMinMaxPK returns the min and max primary key values +func (t *InitialTransfer) getMinMaxPK(dbName, tableName, pkColumn string) (*MinMax, error) { + query := fmt.Sprintf( + "SELECT COALESCE(MIN(`%s`), 0), COALESCE(MAX(`%s`), 0) FROM `%s`.`%s`", + pkColumn, pkColumn, dbName, tableName, + ) + + var minVal, maxVal int64 + err := t.primaryDB.QueryRow(query).Scan(&minVal, &maxVal) + if err != nil { + return nil, err + } + + return &MinMax{Min: minVal, Max: maxVal + 1}, nil +} + +// transferTableFullScan transfers a table without primary key (slower, uses LIMIT/OFFSET) +func (t *InitialTransfer) transferTableFullScan(dbName, tableName string, columns []ColumnInfo, rowCount int64) error { + tableKey := dbName + "." + tableName + + // Get starting offset from progress + startOffset := t.progress.TablesProcessed[tableKey] + + var offset int64 = startOffset + + for offset < rowCount { + // Check for pause signal + t.checkPause() + + query := fmt.Sprintf( + "SELECT * FROM `%s`.`%s` LIMIT %d OFFSET %d", + dbName, tableName, t.batchSize, offset, + ) + + rows, err := t.primaryDB.Query(query) + if err != nil { + return fmt.Errorf("failed to query chunk: %v", err) + } + + rowsInserted, err := t.insertRows(t.secondaryDB, dbName, tableName, columns, rows) + rows.Close() + + if err != nil { + return fmt.Errorf("failed to insert chunk: %v", err) + } + + // Update progress + t.mu.Lock() + t.progress.TablesProcessed[tableKey] = offset + t.mu.Unlock() + + // Save checkpoint every 1000 rows + if rowsInserted%1000 == 0 { + t.saveProgress() + } + + offset += int64(t.batchSize) + } + + return nil +} + +// insertRows inserts rows from a query result into the secondary database +func (t *InitialTransfer) insertRows(db *sql.DB, dbName, tableName string, columns []ColumnInfo, rows *sql.Rows) (int64, error) { + // Build INSERT statement + placeholders := make([]string, len(columns)) + colNames := make([]string, len(columns)) + for i := range columns { + placeholders[i] = "?" + colNames[i] = fmt.Sprintf("`%s`", columns[i].Name) + } + + insertSQL := fmt.Sprintf( + "INSERT INTO `%s`.`%s` (%s) VALUES (%s)", + dbName, tableName, strings.Join(colNames, ", "), strings.Join(placeholders, ", "), + ) + + // Prepare statement + stmt, err := db.Prepare(insertSQL) + if err != nil { + return 0, fmt.Errorf("failed to prepare statement: %v", err) + } + defer stmt.Close() + + // Insert rows + rowCount := int64(0) + for rows.Next() { + values := make([]interface{}, len(columns)) + valuePtrs := make([]interface{}, len(columns)) + + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return rowCount, fmt.Errorf("failed to scan row: %v", err) + } + + _, err := stmt.Exec(values...) + if err != nil { + return rowCount, fmt.Errorf("failed to insert row: %v", err) + } + rowCount++ + } + + t.mu.Lock() + t.stats.TotalRows += rowCount + t.stats.TotalTables++ + t.mu.Unlock() + + Infof("[TRANSFER] Inserted %d rows into %s.%s", rowCount, dbName, tableName) + + return rowCount, rows.Err() +} + +// getBinlogPosition returns the current binlog position +func (t *InitialTransfer) getBinlogPosition() (string, error) { + var file string + var pos uint32 + var doDB, ignoreDB string // Ignore extra columns from SHOW MASTER STATUS + + err := t.primaryDB.QueryRow("SHOW MASTER STATUS").Scan(&file, &pos, &doDB, &ignoreDB) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + + return fmt.Sprintf("%s:%d", file, pos), nil +} + +// Close closes the database connections +func (t *InitialTransfer) Close() error { + if err := t.primaryDB.Close(); err != nil { + return err + } + return t.secondaryDB.Close() +} + +// GetStats returns the transfer statistics +func (t *InitialTransfer) GetStats() TransferStats { + t.mu.Lock() + defer t.mu.Unlock() + return t.stats +} diff --git a/pkg/replica/logging.go b/pkg/replica/logging.go new file mode 100644 index 0000000..c3444fd --- /dev/null +++ b/pkg/replica/logging.go @@ -0,0 +1,393 @@ +package replica + +import ( + "encoding/json" + "fmt" + "log/slog" + "net" + "os" + "sync" + "time" +) + +// LogLevel represents the logging level +type LogLevel slog.Level + +const ( + LevelDebug LogLevel = LogLevel(slog.LevelDebug) + LevelInfo LogLevel = LogLevel(slog.LevelInfo) + LevelWarn LogLevel = LogLevel(slog.LevelWarn) + LevelError LogLevel = LogLevel(slog.LevelError) +) + +// Logger is a structured logger with optional Graylog support +type Logger struct { + logger *slog.Logger + graylogMu sync.RWMutex + graylog *GraylogClient + level LogLevel + source string +} + +// GraylogClient handles sending logs to Graylog +type GraylogClient struct { + conn net.Conn + source string + extra map[string]interface{} + mu sync.Mutex + connected bool +} + +// GELFMessage represents a GELF message structure +type GELFMessage struct { + Version string `json:"version"` + Host string `json:"host"` + ShortMessage string `json:"short_message"` + FullMessage string `json:"full_message,omitempty"` + Timestamp float64 `json:"timestamp"` + Level int `json:"level"` + Source string `json:"source,omitempty"` + Extra map[string]interface{} `json:"-"` +} + +// NewGraylogClient creates a new Graylog client +func NewGraylogClient(cfg GraylogConfig) (*GraylogClient, error) { + var conn net.Conn + var err error + + protocol := cfg.Protocol + if protocol == "" { + protocol = "udp" + } + + conn, err = net.DialTimeout(protocol, cfg.Endpoint, cfg.Timeout) + if err != nil { + return nil, fmt.Errorf("failed to connect to Graylog: %w", err) + } + + return &GraylogClient{ + conn: conn, + source: cfg.Source, + extra: cfg.ExtraFields, + connected: true, + }, nil +} + +// send sends a GELF message to Graylog +func (g *GraylogClient) send(msg *GELFMessage) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.connected { + return nil + } + + // Add extra fields + if g.extra != nil { + msg.Extra = g.extra + } + + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal GELF message: %w", err) + } + + // Add null byte as delimiter for UDP + var delimiter byte + if udpConn, ok := g.conn.(*net.UDPConn); ok { + delimiter = 0 + _, err = udpConn.Write(append(data, delimiter)) + } else { + _, err = g.conn.Write(data) + } + + if err != nil { + g.connected = false + return fmt.Errorf("failed to send to Graylog: %w", err) + } + + return nil +} + +// Close closes the Graylog connection +func (g *GraylogClient) Close() error { + g.mu.Lock() + defer g.mu.Unlock() + + if g.conn != nil { + g.connected = false + return g.conn.Close() + } + return nil +} + +// NewLogger creates a new logger instance +func NewLogger() *Logger { + // Create slog handler with JSON output for structured logging + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + + return &Logger{ + logger: slog.New(handler), + level: LevelInfo, + source: "binlog-sync", + } +} + +// NewLoggerWithLevel creates a new logger with specified level +func NewLoggerWithLevel(level LogLevel) *Logger { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.Level(level), + }) + + return &Logger{ + logger: slog.New(handler), + level: level, + source: "binlog-sync", + } +} + +// SetupGraylog configures Graylog integration +func (l *Logger) SetupGraylog(cfg GraylogConfig) error { + client, err := NewGraylogClient(cfg) + if err != nil { + return err + } + + l.graylogMu.Lock() + l.graylog = client + l.graylogMu.Unlock() + + return nil +} + +// With creates a new logger with additional context +func (l *Logger) With(args ...any) *Logger { + return &Logger{ + logger: l.logger.With(args...), + level: l.level, + source: l.source, + } +} + +// Debug logs a debug message +func (l *Logger) Debug(msg string, args ...any) { + l.logger.Debug(msg, args...) + l.sendToGraylog(slog.LevelDebug, msg, args) +} + +// Info logs an info message +func (l *Logger) Info(msg string, args ...any) { + l.logger.Info(msg, args...) + l.sendToGraylog(slog.LevelInfo, msg, args) +} + +// Warn logs a warning message +func (l *Logger) Warn(msg string, args ...any) { + l.logger.Warn(msg, args...) + l.sendToGraylog(slog.LevelWarn, msg, args) +} + +// Error logs an error message +func (l *Logger) Error(msg string, args ...any) { + l.logger.Error(msg, args...) + l.sendToGraylog(slog.LevelError, msg, args) +} + +// Fatal logs a fatal message and exits +func (l *Logger) Fatal(msg string, args ...any) { + l.logger.Error(msg, args...) + l.sendToGraylog(slog.LevelError, msg, args) + os.Exit(1) +} + +// Debugf logs a formatted debug message +func (l *Logger) Debugf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + l.logger.Debug(msg) + l.sendToGraylog(slog.LevelDebug, msg, nil) +} + +// Infof logs a formatted info message +func (l *Logger) Infof(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + l.logger.Info(msg) + l.sendToGraylog(slog.LevelInfo, msg, nil) +} + +// Warnf logs a formatted warning message +func (l *Logger) Warnf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + l.logger.Warn(msg) + l.sendToGraylog(slog.LevelWarn, msg, nil) +} + +// Errorf logs a formatted error message +func (l *Logger) Errorf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + l.logger.Error(msg) + l.sendToGraylog(slog.LevelError, msg, nil) +} + +// Fatalf logs a formatted fatal message and exits +func (l *Logger) Fatalf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + l.logger.Error(msg) + l.sendToGraylog(slog.LevelError, msg, nil) + os.Exit(1) +} + +// Log logs a message at the specified level +func (l *Logger) Log(level LogLevel, msg string, args ...any) { + switch level { + case LevelDebug: + l.logger.Debug(msg, args...) + case LevelInfo: + l.logger.Info(msg, args...) + case LevelWarn: + l.logger.Warn(msg, args...) + case LevelError: + l.logger.Error(msg, args...) + } + l.sendToGraylog(slog.Level(level), msg, args) +} + +// sendToGraylog sends a log message to Graylog if configured +func (l *Logger) sendToGraylog(level slog.Level, msg string, args []any) { + l.graylogMu.RLock() + defer l.graylogMu.RUnlock() + + if l.graylog == nil { + return + } + + // Convert args to structured data + extra := make(map[string]interface{}) + if args != nil { + for i := 0; i < len(args)-1; i += 2 { + if key, ok := args[i].(string); ok { + extra[key] = args[i+1] + } + } + } + + gelfMsg := &GELFMessage{ + Version: "1.1", + Host: l.source, + ShortMessage: msg, + Timestamp: float64(time.Now().UnixNano()) / 1e9, + Level: int(level) + 1, // GELF levels: 1=emerg, 2=alert, 3=crit, 4=err, 5=warn, 6=notice, 7=info, 8=debug + Source: l.source, + Extra: extra, + } + + // Send in background to not block + go func() { + _ = l.graylog.send(gelfMsg) + }() +} + +// Close closes the logger and any underlying connections +func (l *Logger) Close() { + l.graylogMu.Lock() + defer l.graylogMu.Unlock() + + if l.graylog != nil { + l.graylog.Close() + l.graylog = nil + } +} + +// GetLogger returns the underlying slog.Logger +func (l *Logger) GetLogger() *slog.Logger { + return l.logger +} + +// Handler returns a slog.Handler for use with other libraries +func (l *Logger) Handler() slog.Handler { + return l.logger.Handler() +} + +// Global logger instance - renamed to avoid conflict with log package +var globalLogger *Logger + +// Init initializes the global logger +func InitLogger() { + globalLogger = NewLogger() +} + +// InitLoggerWithLevel initializes the global logger with a specific level +func InitLoggerWithLevel(level LogLevel) { + globalLogger = NewLoggerWithLevel(level) +} + +// SetupGlobalGraylog configures Graylog for the global logger +func SetupGlobalGraylog(cfg GraylogConfig) error { + return globalLogger.SetupGraylog(cfg) +} + +// GetLogger returns the global logger instance +func GetLogger() *Logger { + if globalLogger == nil { + InitLogger() + } + return globalLogger +} + +// Convenience functions using global logger + +// Debug logs a debug message +func Debug(msg string, args ...any) { + GetLogger().Debug(msg, args...) +} + +// Info logs an info message +func Info(msg string, args ...any) { + GetLogger().Info(msg, args...) +} + +// Warn logs a warning message +func Warn(msg string, args ...any) { + GetLogger().Warn(msg, args...) +} + +// Error logs an error message +func Error(msg string, args ...any) { + GetLogger().Error(msg, args...) +} + +// Fatal logs a fatal message and exits +func Fatal(msg string, args ...any) { + GetLogger().Fatal(msg, args...) +} + +// Debugf logs a formatted debug message +func Debugf(format string, args ...any) { + GetLogger().Debugf(format, args...) +} + +// Infof logs a formatted info message +func Infof(format string, args ...any) { + GetLogger().Infof(format, args...) +} + +// Warnf logs a formatted warning message +func Warnf(format string, args ...any) { + GetLogger().Warnf(format, args...) +} + +// Errorf logs a formatted error message +func Errorf(format string, args ...any) { + GetLogger().Errorf(format, args...) +} + +// Fatalf logs a formatted fatal message and exits +func Fatalf(format string, args ...any) { + GetLogger().Fatalf(format, args...) +} + +// Log logs a message at the specified level +func Log(level LogLevel, msg string, args ...any) { + GetLogger().Log(level, msg, args...) +} diff --git a/pkg/replica/position.go b/pkg/replica/position.go new file mode 100644 index 0000000..3fb4f04 --- /dev/null +++ b/pkg/replica/position.go @@ -0,0 +1,140 @@ +package replica + +import ( + "database/sql" + "encoding/json" + "os" + + "github.com/go-mysql-org/go-mysql/mysql" +) + +// PositionManager handles binlog position persistence +type PositionManager struct { + db *sql.DB + positionFile string +} + +// NewPositionManager creates a new position manager +func NewPositionManager(db *sql.DB, positionFile string) *PositionManager { + return &PositionManager{ + db: db, + positionFile: positionFile, + } +} + +// InitTable creates the binlog_position table if it doesn't exist +func (pm *PositionManager) InitTable() error { + if pm.db == nil { + return nil + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS binlog_position ( + id INT PRIMARY KEY, + log_file VARCHAR(255), + log_pos BIGINT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + ` + + _, err := pm.db.Exec(createTableSQL) + if err != nil { + Errorf("[ERROR] Failed to create binlog_position table: %v", err) + return err + } + + return nil +} + +// Load loads the last saved position from database or file +func (pm *PositionManager) Load() (mysql.Position, error) { + if pm.db != nil { + return pm.loadFromDB() + } + return pm.loadFromFile() +} + +func (pm *PositionManager) loadFromDB() (mysql.Position, error) { + var name string + var pos uint32 + + err := pm.db.QueryRow( + "SELECT log_file, log_pos FROM binlog_position WHERE id = 1", + ).Scan(&name, &pos) + + if err == sql.ErrNoRows { + return mysql.Position{Name: "", Pos: 0}, nil + } + if err != nil { + return mysql.Position{Name: "", Pos: 0}, err + } + + Infof("[INFO] Loaded position: %s:%d", name, pos) + return mysql.Position{Name: name, Pos: pos}, nil +} + +func (pm *PositionManager) loadFromFile() (mysql.Position, error) { + data, err := os.ReadFile(pm.positionFile) + if err != nil { + return mysql.Position{Name: "", Pos: 0}, nil + } + + var pos mysql.Position + err = json.Unmarshal(data, &pos) + return pos, err +} + +// Save saves the current position to database or file +func (pm *PositionManager) Save(pos mysql.Position) error { + if pm.db != nil { + return pm.saveToDB(pos) + } + return pm.saveToFile(pos) +} + +// Reset clears the saved position +func (pm *PositionManager) Reset() error { + if pm.db != nil { + return pm.resetInDB() + } + return pm.resetInFile() +} + +func (pm *PositionManager) resetInDB() error { + _, err := pm.db.Exec("DELETE FROM binlog_position WHERE id = 1") + return err +} + +func (pm *PositionManager) resetInFile() error { + return os.Remove(pm.positionFile) +} + +func (pm *PositionManager) saveToDB(pos mysql.Position) error { + result, err := pm.db.Exec( + "INSERT INTO binlog_position (id, log_file, log_pos) VALUES (1, ?, ?) "+ + "ON DUPLICATE KEY UPDATE log_file = VALUES(log_file), log_pos = VALUES(log_pos)", + pos.Name, pos.Pos, + ) + + if err != nil { + Errorf("[ERROR] Failed to save position: %v", err) + return err + } + + rowsAffected, _ := result.RowsAffected() + Infof("[INFO] Saved position: %s:%d (rows: %d)", pos.Name, pos.Pos, rowsAffected) + return nil +} + +func (pm *PositionManager) saveToFile(pos mysql.Position) error { + data, err := json.Marshal(pos) + if err != nil { + return err + } + return os.WriteFile(pm.positionFile, data, 0644) +} + +// GetPositionFile returns the position file path +func (pm *PositionManager) GetPositionFile() string { + return pm.positionFile +} diff --git a/pkg/replica/service.go b/pkg/replica/service.go new file mode 100644 index 0000000..798b51b --- /dev/null +++ b/pkg/replica/service.go @@ -0,0 +1,397 @@ +package replica + +import ( + "context" + "database/sql" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/go-mysql-org/go-mysql/mysql" + "github.com/go-mysql-org/go-mysql/replication" + _ "github.com/go-sql-driver/mysql" +) + +// BinlogSyncService handles MySQL binlog streaming with resilience features +type BinlogSyncService struct { + syncer *replication.BinlogSyncer + streamer *replication.BinlogStreamer + position mysql.Position + handlers *EventHandlers + positionMgr *PositionManager + stopChan chan struct{} + instanceName string + secondaryName string + primaryDB *sql.DB + lastEventPos uint32 // for deduplication + lastEventGTID string // for GTID-based deduplication (future) + healthTicker *time.Ticker // for periodic health checks +} + +// NewBinlogSyncService creates a new binlog sync service +func NewBinlogSyncService(cfg BinlogConfig, primaryDB, secondaryDB *sql.DB, secondaryName string) *BinlogSyncService { + syncerCfg := replication.BinlogSyncerConfig{ + ServerID: cfg.ServerID, + Flavor: "mariadb", + Host: cfg.Host, + Port: cfg.Port, + User: cfg.User, + Password: cfg.Password, + } + + positionFile := fmt.Sprintf("binlog_position_%s_%s.json", cfg.Name, secondaryName) + + return &BinlogSyncService{ + syncer: replication.NewBinlogSyncer(syncerCfg), + handlers: NewEventHandlers(secondaryDB, secondaryName), + positionMgr: NewPositionManager(secondaryDB, positionFile), + stopChan: make(chan struct{}), + instanceName: cfg.Name, + secondaryName: secondaryName, + primaryDB: primaryDB, + } +} + +// Start begins binlog streaming +func (s *BinlogSyncService) Start() error { + if err := s.positionMgr.InitTable(); err != nil { + Warnf("[%s][WARN] Failed to init position table: %v", s.secondaryName, err) + } + + pos, err := s.positionMgr.Load() + if err != nil { + return fmt.Errorf("failed to load position: %v", err) + } + + if pos.Name == "" { + s.streamer, err = s.syncer.StartSync(mysql.Position{Name: "", Pos: 4}) + } else { + s.streamer, err = s.syncer.StartSync(pos) + } + + if err != nil { + return fmt.Errorf("failed to start sync: %v", err) + } + + Infof("[%s] Started streaming from %s:%d", s.instanceName, pos.Name, pos.Pos) + return s.processEvents() +} + +// StartWithStop begins binlog streaming with graceful shutdown +func (s *BinlogSyncService) StartWithStop() error { + if err := s.positionMgr.InitTable(); err != nil { + Warnf("[%s][WARN] Failed to init position table: %v", s.secondaryName, err) + } + + pos, err := s.positionMgr.Load() + if err != nil { + return fmt.Errorf("failed to load position: %v", err) + } + + if pos.Name == "" { + s.streamer, err = s.syncer.StartSync(mysql.Position{Name: "", Pos: 4}) + } else { + s.streamer, err = s.syncer.StartSync(pos) + } + + if err != nil { + return fmt.Errorf("failed to start sync: %v", err) + } + + Infof("[%s] Started streaming from %s:%d", s.instanceName, pos.Name, pos.Pos) + return s.processEventsWithStop() +} + +// processEventsWithStop processes events with graceful shutdown and health checks +func (s *BinlogSyncService) processEventsWithStop() error { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start health check ticker (every 30 seconds) + s.healthTicker = time.NewTicker(30 * time.Second) + defer s.healthTicker.Stop() + + // Start health check goroutine + healthChan := make(chan error, 1) + go s.runHealthChecks(healthChan) + + for { + select { + case <-sigChan: + Infof("[%s] Shutting down...", s.secondaryName) + s.positionMgr.Save(s.position) + s.syncer.Close() + return nil + case <-s.stopChan: + Infof("[%s] Stop signal received", s.secondaryName) + s.positionMgr.Save(s.position) + s.syncer.Close() + return nil + case <-s.healthTicker.C: + // Trigger health check + go s.runHealthChecks(healthChan) + case healthErr := <-healthChan: + if healthErr != nil { + Errorf("[%s][HEALTH CHECK] Failed: %v", s.secondaryName, healthErr) + } + default: + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ev, err := s.streamer.GetEvent(ctx) + cancel() + + if err == context.DeadlineExceeded { + continue + } + if err != nil { + return fmt.Errorf("failed to get event: %v", err) + } + + s.processEvent(ev) + } + } +} + +// runHealthChecks performs periodic health checks +func (s *BinlogSyncService) runHealthChecks(resultChan chan<- error) { + // Check secondary DB connection + if err := s.handlers.PingSecondary(); err != nil { + resultChan <- fmt.Errorf("secondary DB ping failed: %v", err) + return + } + + // Check if syncer is still healthy + if s.syncer == nil { + resultChan <- fmt.Errorf("syncer is nil") + return + } + + resultChan <- nil +} + +// processEvents processes events continuously +func (s *BinlogSyncService) processEvents() error { + for { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ev, err := s.streamer.GetEvent(ctx) + cancel() + + if err == context.DeadlineExceeded { + continue + } + if err != nil { + return fmt.Errorf("failed to get event: %v", err) + } + + s.processEvent(ev) + } +} + +// processEvent processes a single event with panic recovery +// Note: Deduplication disabled as it was causing valid events to be skipped +func (s *BinlogSyncService) processEvent(ev *replication.BinlogEvent) { + defer func() { + if r := recover(); r != nil { + Errorf("[%s][PANIC RECOVERED] processEvent panic: %v", s.secondaryName, r) + } + }() + + s.position.Pos = ev.Header.LogPos + + switch e := ev.Event.(type) { + case *replication.RotateEvent: + s.position.Name = string(e.NextLogName) + Infof("[%s] Rotated to %s", s.instanceName, s.position.Name) + + case *replication.TableMapEvent: + s.handlers.HandleTableMap(e) + + case *replication.RowsEvent: + s.handlers.HandleRows(ev.Header, e) + + case *replication.QueryEvent: + s.handlers.HandleQuery(e) + + case *replication.XIDEvent: + s.positionMgr.Save(s.position) + } + + if ev.Header.LogPos%1000 == 0 { + s.positionMgr.Save(s.position) + } +} + +// Stop signals the service to stop +func (s *BinlogSyncService) Stop() { + close(s.stopChan) +} + +// NeedResync checks if a resync is needed +func (s *BinlogSyncService) NeedResync() (bool, error) { + // Check if position file/table exists + pos, err := s.positionMgr.Load() + if err != nil { + return false, err + } + + // If no position saved, we need initial transfer + if pos.Name == "" { + Infof("[%s] No saved position found, resync needed", s.secondaryName) + return true, nil + } + + // Check if secondary DB has any tables/data + hasData, err := s.checkSecondaryHasData() + if err != nil { + return false, err + } + + if !hasData { + Infof("[%s] Secondary database is empty, resync needed", s.secondaryName) + return true, nil + } + + return false, nil +} + +// checkSecondaryHasData checks if the secondary database has any data +func (s *BinlogSyncService) checkSecondaryHasData() (bool, error) { + // Get the secondary DB from position manager + db := s.positionMgr.db + if db == nil { + return true, nil // Can't check, assume OK + } + + // Check if any table has data + var count int + err := db.QueryRow("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'replica'").Scan(&count) + if err != nil { + return false, err + } + + return count > 0, nil +} + +// RunInitialTransfer performs the initial data transfer +func (s *BinlogSyncService) RunInitialTransfer(batchSize int, excludeSchemas []string) error { + Infof("[%s] Starting initial data transfer...", s.secondaryName) + + // Get secondary DB from handlers + secondaryDB := s.handlers.secondaryDB + if secondaryDB == nil { + return fmt.Errorf("secondary DB not available") + } + + // Create initial transfer with proper initialization + transfer := &InitialTransfer{ + primaryDB: s.primaryDB, + secondaryDB: secondaryDB, + batchSize: batchSize, + workerCount: 1, + excludedDBs: map[string]bool{ + "information_schema": true, + "performance_schema": true, + "mysql": true, + "sys": true, + }, + checkpointFile: fmt.Sprintf("transfer_progress_%s_%s.json", s.instanceName, s.secondaryName), + progress: TransferProgress{ + TablesProcessed: make(map[string]int64), + }, + } + + if err := transfer.Transfer(excludeSchemas); err != nil { + return fmt.Errorf("transfer failed: %v", err) + } + + // Reset position after successful transfer + if err := s.positionMgr.Reset(); err != nil { + return fmt.Errorf("failed to reset position: %v", err) + } + + Infof("[%s] Initial transfer completed successfully", s.secondaryName) + return nil +} + +// StartWithResync starts binlog streaming with automatic resync if needed +func (s *BinlogSyncService) StartWithResync(batchSize int, excludeSchemas []string) error { + if err := s.positionMgr.InitTable(); err != nil { + Warnf("[%s][WARN] Failed to init position table: %v", s.secondaryName, err) + } + + // Check if resync is needed + needResync, err := s.NeedResync() + if err != nil { + return fmt.Errorf("failed to check resync status: %v", err) + } + + if needResync { + if err := s.RunInitialTransfer(batchSize, excludeSchemas); err != nil { + return fmt.Errorf("initial transfer failed: %v", err) + } + } + + // Start normal streaming + return s.StartWithStop() +} + +// GetSecondaryName returns the secondary name +func (s *BinlogSyncService) GetSecondaryName() string { + return s.secondaryName +} + +// MultiBinlogSyncService manages multiple binlog sync services +type MultiBinlogSyncService struct { + services []*BinlogSyncService + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewMultiBinlogSyncService creates a new multi-secondary binlog sync service +func NewMultiBinlogSyncService() *MultiBinlogSyncService { + return &MultiBinlogSyncService{ + services: make([]*BinlogSyncService, 0), + stopChan: make(chan struct{}), + } +} + +// AddService adds a service to the multi-service manager +func (m *MultiBinlogSyncService) AddService(service *BinlogSyncService) { + m.services = append(m.services, service) +} + +// StartAll starts all services +func (m *MultiBinlogSyncService) StartAll() error { + for _, service := range m.services { + m.wg.Add(1) + go func(svc *BinlogSyncService) { + defer m.wg.Done() + Infof("[%s] Starting binlog sync service", svc.GetSecondaryName()) + if err := svc.StartWithResync(1000, []string{"information_schema", "performance_schema", "mysql", "sys"}); err != nil { + Errorf("[%s] Service error: %v", svc.GetSecondaryName(), err) + } + }(service) + } + return nil +} + +// StopAll stops all services +func (m *MultiBinlogSyncService) StopAll() { + close(m.stopChan) + for _, service := range m.services { + service.Stop() + } + m.wg.Wait() +} + +// Wait waits for all services to complete +func (m *MultiBinlogSyncService) Wait() { + m.wg.Wait() +} + +// Len returns the number of services +func (m *MultiBinlogSyncService) Len() int { + return len(m.services) +} diff --git a/pkg/replica/sqlbuilder.go b/pkg/replica/sqlbuilder.go new file mode 100644 index 0000000..6e7b72f --- /dev/null +++ b/pkg/replica/sqlbuilder.go @@ -0,0 +1,168 @@ +package replica + +import ( + "fmt" + "strings" + + "github.com/go-mysql-org/go-mysql/replication" +) + +// SQLBuilder handles SQL statement building +type SQLBuilder struct{} + +// NewSQLBuilder creates a new SQL builder +func NewSQLBuilder() *SQLBuilder { + return &SQLBuilder{} +} + +// BuildInsert builds an INSERT statement +func (sb *SQLBuilder) BuildInsert(schema, table string, tableMap *replication.TableMapEvent, row []interface{}) string { + if len(row) == 0 { + return fmt.Sprintf("INSERT INTO `%s`.`%s` VALUES ()", schema, table) + } + + var columns []string + var values []string + + for i, col := range row { + colName := sb.getColumnName(tableMap, i) + columns = append(columns, colName) + values = append(values, formatValue(col)) + } + + return fmt.Sprintf("INSERT INTO `%s`.`%s` (%s) VALUES (%s)", + schema, table, strings.Join(columns, ", "), strings.Join(values, ", ")) +} + +// BuildUpdate builds an UPDATE statement +func (sb *SQLBuilder) BuildUpdate(schema, table string, tableMap *replication.TableMapEvent, before, after []interface{}) string { + if len(before) == 0 || len(after) == 0 { + return fmt.Sprintf("UPDATE `%s`.`%s` SET id = id", schema, table) + } + + var setClauses []string + var whereClauses []string + + for i := range before { + colName := sb.getColumnName(tableMap, i) + if !valuesEqual(before[i], after[i]) { + setClauses = append(setClauses, fmt.Sprintf("%s = %s", colName, formatValue(after[i]))) + } + if i == 0 { + whereClauses = append(whereClauses, fmt.Sprintf("%s = %s", colName, formatValue(before[i]))) + } + } + + return fmt.Sprintf("UPDATE `%s`.`%s` SET %s WHERE %s", + schema, table, strings.Join(setClauses, ", "), strings.Join(whereClauses, " AND ")) +} + +// BuildDelete builds a DELETE statement +func (sb *SQLBuilder) BuildDelete(schema, table string, tableMap *replication.TableMapEvent, row []interface{}) string { + if len(row) == 0 { + return fmt.Sprintf("DELETE FROM `%s`.`%s` WHERE 1=0", schema, table) + } + + colName := sb.getColumnName(tableMap, 0) + whereClause := fmt.Sprintf("%s = %s", colName, formatValue(row[0])) + return fmt.Sprintf("DELETE FROM `%s`.`%s` WHERE %s", schema, table, whereClause) +} + +// getColumnName returns the column name at the given index +func (sb *SQLBuilder) getColumnName(tableMap *replication.TableMapEvent, index int) string { + if tableMap == nil || index >= len(tableMap.ColumnName) { + return fmt.Sprintf("`col_%d`", index) + } + return fmt.Sprintf("`%s`", tableMap.ColumnName[index]) +} + +// formatValue formats a value for SQL +func formatValue(col interface{}) string { + switch v := col.(type) { + case []byte: + if str := string(v); validUTF8(v) { + return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "''")) + } + return fmt.Sprintf("X'%s'", hexEncode(v)) + case string: + return fmt.Sprintf("'%s'", strings.ReplaceAll(v, "'", "''")) + case nil: + return "NULL" + default: + return fmt.Sprintf("'%v'", v) + } +} + +func validUTF8(b []byte) bool { + for i := 0; i < len(b); { + if b[i] < 0x80 { + i++ + } else if (b[i] & 0xE0) == 0xC0 { + if i+1 >= len(b) || (b[i+1]&0xC0) != 0x80 { + return false + } + i += 2 + } else if (b[i] & 0xF0) == 0xE0 { + if i+2 >= len(b) || (b[i+1]&0xC0) != 0x80 || (b[i+2]&0xC0) != 0x80 { + return false + } + i += 3 + } else if (b[i] & 0xF8) == 0xF0 { + if i+3 >= len(b) || (b[i+1]&0xC0) != 0x80 || (b[i+2]&0xC0) != 0x80 || (b[i+3]&0xC0) != 0x80 { + return false + } + i += 4 + } else { + return false + } + } + return true +} + +func hexEncode(b []byte) string { + hexChars := "0123456789ABCDEF" + result := make([]byte, len(b)*2) + for i, byteVal := range b { + result[i*2] = hexChars[byteVal>>4] + result[i*2+1] = hexChars[byteVal&0x0F] + } + return string(result) +} + +// valuesEqual compares two values, handling slices properly +func valuesEqual(a, b interface{}) bool { + // Handle nil cases + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + + // Handle byte slices specially + if aBytes, ok := a.([]byte); ok { + if bBytes, ok := b.([]byte); ok { + return bytesEqual(aBytes, bBytes) + } + return false + } + if _, ok := b.([]byte); ok { + return false + } + + // For other types, use fmt.Sprintf comparison + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} + +// bytesEqual compares two byte slices +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/replica b/replica new file mode 100755 index 0000000000000000000000000000000000000000..4939496a44e5c6334747bb743fd78d9194dac5be GIT binary patch literal 12481215 zcmeFad3==B**88}8DzN!0SN*+=wOr9B|*_dh)y8D8JIvQs6kMpVvN>mATtmFF))c_ zI*gsFeX5VPwTsJuVxQfhJW*oa`TOH=5>Kl z&b%@&|FL|Qyfc5C#*Ml@U3iUrHm_zsSx(mBROuGy3RTXyRz7>Jd^WF%

)~=L0H# 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