/* * Copyright 2026 Safronov Grigorii * * Licensed under the CDDL, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * https://opensource.org/licenses/CDDL-1.0 */ package backup import ( "bytes" "compress/gzip" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "sync" "sync/atomic" "time" "futriis/internal/config" "futriis/internal/storage" ) type BackupType string const ( FullBackup BackupType = "full" IncrementalBackup BackupType = "incremental" ) type BackupSchedule struct { ID string `json:"id"` Name string `json:"name"` CronExpr string `json:"cron_expr"` Type BackupType `json:"type"` RetentionDays int `json:"retention_days"` Enabled bool `json:"enabled"` LastRun int64 `json:"last_run"` NextRun int64 `json:"next_run"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type BackupInfo struct { ID string `json:"id"` ScheduleID string `json:"schedule_id"` Type BackupType `json:"type"` StartTime int64 `json:"start_time"` EndTime int64 `json:"end_time"` Status string `json:"status"` SizeBytes int64 `json:"size_bytes"` Path string `json:"path"` WALStart uint64 `json:"wal_start"` WALEnd uint64 `json:"wal_end"` ParentID string `json:"parent_id,omitempty"` Checksum string `json:"checksum"` Error string `json:"error,omitempty"` SagaCount int `json:"saga_count,omitempty"` SagaID string `json:"saga_id,omitempty"` } type WALEntry struct { Index uint64 `json:"index"` Term uint64 `json:"term"` Type string `json:"type"` Data []byte `json:"data"` Timestamp int64 `json:"timestamp"` } type WALReader interface { ReadSince(index uint64) ([]WALEntry, error) GetCurrentIndex() (uint64, error) GetLastBackupIndex() uint64 SetLastBackupIndex(index uint64) error GetSegments() ([]string, error) ReadSegment(segmentPath string) ([]WALEntry, error) } type BackupStorage interface { SaveBackup(path string, data []byte) error LoadBackup(path string) ([]byte, error) ListBackups() ([]string, error) DeleteBackup(path string) error BackupExists(path string) bool } type LoggerInterface interface { Info(msg string) Warn(msg string) Error(msg string) Debug(msg string) } type FileBackupStorage struct { backupDir string compressLevel int mu sync.Mutex } func NewFileBackupStorage(backupDir string, compressLevel int) (*FileBackupStorage, error) { if compressLevel < 1 || compressLevel > 9 { compressLevel = 6 } if err := os.MkdirAll(backupDir, 0755); err != nil { return nil, fmt.Errorf("failed to create backup dir: %v", err) } return &FileBackupStorage{ backupDir: backupDir, compressLevel: compressLevel, }, nil } func (fbs *FileBackupStorage) SaveBackup(path string, data []byte) error { fbs.mu.Lock() defer fbs.mu.Unlock() fullPath := filepath.Join(fbs.backupDir, path) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return err } var buf bytes.Buffer gzWriter, err := gzip.NewWriterLevel(&buf, fbs.compressLevel) if err != nil { return err } if _, err := gzWriter.Write(data); err != nil { gzWriter.Close() return err } if err := gzWriter.Close(); err != nil { return err } compressedPath := fullPath + ".gz" return os.WriteFile(compressedPath, buf.Bytes(), 0644) } func (fbs *FileBackupStorage) LoadBackup(path string) ([]byte, error) { fbs.mu.Lock() defer fbs.mu.Unlock() fullPath := filepath.Join(fbs.backupDir, path) compressedPath := fullPath + ".gz" var data []byte var err error if _, err := os.Stat(compressedPath); err == nil { compressedData, err := os.ReadFile(compressedPath) if err != nil { return nil, err } gzReader, err := gzip.NewReader(bytes.NewReader(compressedData)) if err != nil { return nil, err } defer gzReader.Close() data, err = io.ReadAll(gzReader) if err != nil { return nil, err } } else { data, err = os.ReadFile(fullPath) if err != nil { return nil, err } } return data, nil } func (fbs *FileBackupStorage) ListBackups() ([]string, error) { entries, err := os.ReadDir(fbs.backupDir) if err != nil { return nil, err } names := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() { subEntries, err := os.ReadDir(filepath.Join(fbs.backupDir, e.Name())) if err == nil { for _, sub := range subEntries { if !sub.IsDir() { names = append(names, filepath.Join(e.Name(), sub.Name())) } } } } else { names = append(names, e.Name()) } } return names, nil } func (fbs *FileBackupStorage) DeleteBackup(path string) error { fbs.mu.Lock() defer fbs.mu.Unlock() fullPath := filepath.Join(fbs.backupDir, path) compressedPath := fullPath + ".gz" var err error if _, statErr := os.Stat(compressedPath); statErr == nil { err = os.Remove(compressedPath) } else { err = os.Remove(fullPath) } dirPath := filepath.Dir(fullPath) os.Remove(dirPath) return err } func (fbs *FileBackupStorage) BackupExists(path string) bool { fullPath := filepath.Join(fbs.backupDir, path) compressedPath := fullPath + ".gz" _, err1 := os.Stat(fullPath) _, err2 := os.Stat(compressedPath) return err1 == nil || err2 == nil } func (fbs *FileBackupStorage) GetTransactionManager() *storage.TransactionManager { return storage.GetTransactionManager() } type WALReaderImpl struct { walManager interface{} lastBackupIndex atomic.Uint64 segmentsDir string mu sync.RWMutex logger LoggerInterface } func NewWALReaderImpl(walManager interface{}, segmentsDir string, logger LoggerInterface) *WALReaderImpl { wr := &WALReaderImpl{ walManager: walManager, segmentsDir: segmentsDir, logger: logger, } wr.loadLastBackupIndex() return wr } func (wr *WALReaderImpl) ReadSince(index uint64) ([]WALEntry, error) { wr.mu.RLock() defer wr.mu.RUnlock() entries := make([]WALEntry, 0) segments, err := wr.GetSegments() if err != nil { return nil, err } for _, segmentPath := range segments { segEntries, err := wr.ReadSegment(segmentPath) if err != nil { if wr.logger != nil { wr.logger.Warn(fmt.Sprintf("Failed to read segment %s: %v", segmentPath, err)) } continue } for _, entry := range segEntries { if entry.Index > index { entries = append(entries, entry) } } } return entries, nil } func (wr *WALReaderImpl) GetCurrentIndex() (uint64, error) { segments, err := wr.GetSegments() if err != nil { return 0, err } if len(segments) == 0 { return 0, nil } lastSegment := segments[len(segments)-1] entries, err := wr.ReadSegment(lastSegment) if err != nil { return 0, err } if len(entries) == 0 { return 0, nil } return entries[len(entries)-1].Index, nil } func (wr *WALReaderImpl) GetLastBackupIndex() uint64 { return wr.lastBackupIndex.Load() } func (wr *WALReaderImpl) SetLastBackupIndex(index uint64) error { wr.lastBackupIndex.Store(index) indexPath := filepath.Join(wr.segmentsDir, "last_backup_index.json") data, err := json.Marshal(map[string]uint64{"last_index": index}) if err != nil { return err } return os.WriteFile(indexPath, data, 0644) } func (wr *WALReaderImpl) GetSegments() ([]string, error) { if wr.segmentsDir == "" { return nil, fmt.Errorf("segments directory not set") } pattern := filepath.Join(wr.segmentsDir, "wal_segment_*.log") files, err := filepath.Glob(pattern) if err != nil { return nil, err } sort.Strings(files) return files, nil } func (wr *WALReaderImpl) ReadSegment(segmentPath string) ([]WALEntry, error) { data, err := os.ReadFile(segmentPath) if err != nil { return nil, err } entries := make([]WALEntry, 0) pos := 0 for pos < len(data) { if pos+4 > len(data) { break } length := int(data[pos])<<24 | int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3]) pos += 4 if pos+length > len(data) { break } recordData := data[pos : pos+length] pos += length var record struct { LSN uint64 `json:"lsn"` Timestamp int64 `json:"timestamp"` Type byte `json:"type"` Data []byte `json:"data"` } if err := json.Unmarshal(recordData, &record); err != nil { continue } entry := WALEntry{ Index: record.LSN, Timestamp: record.Timestamp, Type: fmt.Sprintf("%d", record.Type), Data: record.Data, } entries = append(entries, entry) } return entries, nil } func (wr *WALReaderImpl) loadLastBackupIndex() { indexPath := filepath.Join(wr.segmentsDir, "last_backup_index.json") data, err := os.ReadFile(indexPath) if err != nil { return } var meta map[string]uint64 if err := json.Unmarshal(data, &meta); err != nil { return } if lastIdx, ok := meta["last_index"]; ok { wr.lastBackupIndex.Store(lastIdx) } } type BackupScheduler struct { schedules map[string]*BackupSchedule backups map[string]*BackupInfo walReader WALReader storage BackupStorage logger LoggerInterface mu sync.RWMutex stopChan chan struct{} wg sync.WaitGroup running atomic.Bool backupCounter atomic.Uint64 config *config.BackupConfig sagaManager *storage.SagaManager } func NewBackupScheduler(cfg *config.BackupConfig, walReader WALReader, logger LoggerInterface) (*BackupScheduler, error) { if cfg == nil { return nil, fmt.Errorf("backup config is required") } compressLevel := 6 if cfg.CompressEnabled { compressLevel = 6 } else { compressLevel = 0 } storage, err := NewFileBackupStorage(cfg.BackupDir, compressLevel) if err != nil { return nil, err } bs := &BackupScheduler{ schedules: make(map[string]*BackupSchedule), backups: make(map[string]*BackupInfo), walReader: walReader, storage: storage, logger: logger, stopChan: make(chan struct{}), config: cfg, sagaManager: storage.NewSagaManager(logger), } bs.loadSchedules() bs.loadBackupHistory() return bs, nil } func (bs *BackupScheduler) GetSagaManager() *storage.SagaManager { return bs.sagaManager } func (bs *BackupScheduler) ReloadConfig(cfg *config.BackupConfig) { bs.mu.Lock() defer bs.mu.Unlock() oldBackupDir := bs.config.BackupDir oldCompressEnabled := bs.config.CompressEnabled oldIncludeSagaState := bs.config.IncludeSagaState oldEnableIncremental := bs.config.EnableIncremental bs.config = cfg // Если изменилась директория или сжатие - пересоздаём хранилище if oldBackupDir != cfg.BackupDir || oldCompressEnabled != cfg.CompressEnabled { compressLevel := 6 if cfg.CompressEnabled { compressLevel = 6 } else { compressLevel = 0 } newStorage, err := NewFileBackupStorage(cfg.BackupDir, compressLevel) if err != nil { if bs.logger != nil { bs.logger.Error(fmt.Sprintf("Failed to reload backup storage: %v", err)) } return } bs.storage = newStorage // Перезагружаем данные из нового хранилища bs.loadSchedules() bs.loadBackupHistory() } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Backup scheduler configuration reloaded: enabled=%v, backup_dir=%s, compress=%v, incremental=%v, saga_state=%v", cfg.Enabled, cfg.BackupDir, cfg.CompressEnabled, cfg.EnableIncremental, cfg.IncludeSagaState)) } } func (bs *BackupScheduler) loadSchedules() { data, err := bs.storage.LoadBackup("schedules.json") if err != nil { if !os.IsNotExist(err) && bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to load schedules: %v", err)) } return } var schedules map[string]*BackupSchedule if err := json.Unmarshal(data, &schedules); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to parse schedules: %v", err)) } return } bs.mu.Lock() bs.schedules = schedules bs.mu.Unlock() } func (bs *BackupScheduler) saveSchedules() error { bs.mu.RLock() data, err := json.MarshalIndent(bs.schedules, "", " ") bs.mu.RUnlock() if err != nil { return err } return bs.storage.SaveBackup("schedules.json", data) } func (bs *BackupScheduler) loadBackupHistory() { data, err := bs.storage.LoadBackup("backups.json") if err != nil { if !os.IsNotExist(err) && bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to load backup history: %v", err)) } return } var backups map[string]*BackupInfo if err := json.Unmarshal(data, &backups); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to parse backup history: %v", err)) } return } bs.mu.Lock() bs.backups = backups bs.mu.Unlock() } func (bs *BackupScheduler) saveBackupHistory() error { bs.mu.RLock() data, err := json.MarshalIndent(bs.backups, "", " ") bs.mu.RUnlock() if err != nil { return err } return bs.storage.SaveBackup("backups.json", data) } func (bs *BackupScheduler) AddSchedule(name, cronExpr string, backupType BackupType, retentionDays int) (*BackupSchedule, error) { bs.mu.Lock() defer bs.mu.Unlock() for _, s := range bs.schedules { if s.Name == name { return nil, fmt.Errorf("schedule with name '%s' already exists", name) } } schedule := &BackupSchedule{ ID: fmt.Sprintf("sch_%d", time.Now().UnixNano()), Name: name, CronExpr: cronExpr, Type: backupType, RetentionDays: retentionDays, Enabled: true, CreatedAt: time.Now().UnixMilli(), UpdatedAt: time.Now().UnixMilli(), } bs.schedules[schedule.ID] = schedule if err := bs.saveSchedules(); err != nil { return nil, fmt.Errorf("failed to save schedules: %v", err) } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Added backup schedule: %s (%s) with cron %s", name, backupType, cronExpr)) } return schedule, nil } func (bs *BackupScheduler) RemoveSchedule(scheduleID string) error { bs.mu.Lock() defer bs.mu.Unlock() if _, exists := bs.schedules[scheduleID]; !exists { return fmt.Errorf("schedule not found: %s", scheduleID) } delete(bs.schedules, scheduleID) if err := bs.saveSchedules(); err != nil { return fmt.Errorf("failed to save schedules: %v", err) } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Removed schedule: %s", scheduleID)) } return nil } func (bs *BackupScheduler) RunNow(scheduleID string) error { bs.mu.RLock() schedule, exists := bs.schedules[scheduleID] bs.mu.RUnlock() if !exists { return fmt.Errorf("schedule not found: %s", scheduleID) } if !bs.config.EnableIncremental && schedule.Type == IncrementalBackup { return fmt.Errorf("incremental backups are disabled in configuration") } if schedule.Type == FullBackup { return bs.performFullBackup(scheduleID) } return bs.performIncrementalBackup(scheduleID) } func (bs *BackupScheduler) performFullBackup(scheduleID string) error { if !bs.running.CompareAndSwap(false, true) { return fmt.Errorf("backup already in progress") } defer bs.running.Store(false) startTime := time.Now().UnixMilli() backupID := fmt.Sprintf("full_%d_%d", startTime, bs.backupCounter.Add(1)) if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Starting full backup %s", backupID)) } walIndex, err := bs.walReader.GetCurrentIndex() if err != nil { return fmt.Errorf("failed to get WAL index: %v", err) } var activeSagas []*storage.SagaTransaction var sagaCount int if bs.sagaManager != nil && bs.config.IncludeSagaState { activeSagas = bs.sagaManager.GetActiveSagas() sagaCount = len(activeSagas) if sagaCount > 0 && bs.logger != nil { bs.logger.Info(fmt.Sprintf("Found %d active SAGA transactions, including in backup", sagaCount)) } } if err := bs.walReader.SetLastBackupIndex(walIndex); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to set last backup index: %v", err)) } } walEntries, err := bs.walReader.ReadSince(0) if err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to read WAL for full backup: %v", err)) } walEntries = []WALEntry{} } sagaState := make(map[string]interface{}) if sagaCount > 0 && bs.config.IncludeSagaState { sagaState["active_count"] = sagaCount sagaState["timestamp"] = time.Now().UnixMilli() sagaIDs := make([]string, 0, sagaCount) sagaStatuses := make([]string, 0, sagaCount) for _, saga := range activeSagas { sagaIDs = append(sagaIDs, saga.ID) sagaStatuses = append(sagaStatuses, saga.Status) } sagaState["saga_ids"] = sagaIDs sagaState["saga_statuses"] = sagaStatuses } backupData := struct { BackupID string `json:"backup_id"` Type string `json:"type"` StartTime int64 `json:"start_time"` WALIndex uint64 `json:"wal_index"` Entries []WALEntry `json:"entries,omitempty"` Metadata interface{} `json:"metadata,omitempty"` SagaState interface{} `json:"saga_state,omitempty"` }{ BackupID: backupID, Type: "full", StartTime: startTime, WALIndex: walIndex, Entries: walEntries, Metadata: map[string]interface{}{ "version": "1.0", "created_by": "backup_scheduler", "wal_entries_count": len(walEntries), "saga_active_count": sagaCount, "include_saga_state": bs.config.IncludeSagaState, "backup_config": map[string]interface{}{ "backup_dir": bs.config.BackupDir, "compress_enabled": bs.config.CompressEnabled, "enable_incremental": bs.config.EnableIncremental, "retention_days": bs.config.RetentionDays, }, }, SagaState: sagaState, } data, err := json.Marshal(backupData) if err != nil { return fmt.Errorf("failed to marshal backup data: %v", err) } backupPath := fmt.Sprintf("full/%s/%s.backup", time.Now().Format("2006-01-02"), backupID) if err := bs.storage.SaveBackup(backupPath, data); err != nil { return fmt.Errorf("failed to save backup: %v", err) } hash := sha256.Sum256(data) checksum := hex.EncodeToString(hash[:]) endTime := time.Now().UnixMilli() backupInfo := &BackupInfo{ ID: backupID, ScheduleID: scheduleID, Type: FullBackup, StartTime: startTime, EndTime: endTime, Status: "completed", SizeBytes: int64(len(data)), Path: backupPath, WALStart: 0, WALEnd: walIndex, Checksum: checksum, SagaCount: sagaCount, } bs.mu.Lock() bs.backups[backupID] = backupInfo bs.mu.Unlock() if err := bs.saveBackupHistory(); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to save backup history: %v", err)) } } bs.updateScheduleLastRun(scheduleID, startTime) bs.cleanupOldBackups(scheduleID) duration := (endTime - startTime) / 1000 if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Full backup %s completed in %d seconds, size=%d bytes, entries=%d, active_sagas=%d", backupID, duration, backupInfo.SizeBytes, len(walEntries), sagaCount)) } return nil } func (bs *BackupScheduler) performIncrementalBackup(scheduleID string) error { if !bs.running.CompareAndSwap(false, true) { return fmt.Errorf("backup already in progress") } defer bs.running.Store(false) startTime := time.Now().UnixMilli() backupID := fmt.Sprintf("inc_%d_%d", startTime, bs.backupCounter.Add(1)) sagaID := fmt.Sprintf("backup_saga_%s", backupID) saga := bs.sagaManager.BeginSaga(sagaID) saga.AddStep("validate_parent", func() error { lastFull := bs.findLastFullBackup() if lastFull == nil { return fmt.Errorf("no full backup found") } if !bs.storage.BackupExists(lastFull.Path) { return fmt.Errorf("parent backup file missing: %s", lastFull.ID) } return nil }, func() error { if bs.logger != nil { bs.logger.Warn("Compensating: parent backup validation failed") } return nil }, map[string]interface{}{ "step": "validate_parent", }) saga.AddStep("read_wal", func() error { lastIndex := bs.walReader.GetLastBackupIndex() if lastIndex == 0 { lastFull := bs.findLastFullBackup() if lastFull != nil { lastIndex = lastFull.WALEnd } } entries, err := bs.walReader.ReadSince(lastIndex) if err != nil { return fmt.Errorf("failed to read WAL: %v", err) } if len(entries) == 0 { if bs.logger != nil { bs.logger.Info("No new WAL entries since last backup") } return nil } saga.SetData("wal_entries", entries) saga.SetData("last_index", lastIndex) return nil }, func() error { if bs.logger != nil { bs.logger.Warn("Compensating: WAL read failed") } return nil }, map[string]interface{}{ "step": "read_wal", }) saga.AddStep("get_saga_state", func() error { if bs.sagaManager != nil && bs.config.IncludeSagaState { activeSagas := bs.sagaManager.GetActiveSagas() saga.SetData("active_sagas", activeSagas) saga.SetData("saga_count", len(activeSagas)) if len(activeSagas) > 0 && bs.logger != nil { bs.logger.Info(fmt.Sprintf("Found %d active SAGA transactions in incremental backup", len(activeSagas))) } } return nil }, func() error { if bs.logger != nil { bs.logger.Warn("Compensating: SAGA state retrieval failed") } return nil }, map[string]interface{}{ "step": "get_saga_state", }) saga.AddStep("create_backup_file", func() error { entriesVal, _ := saga.GetData("wal_entries") entries, _ := entriesVal.([]WALEntry) lastIndexVal, _ := saga.GetData("last_index") lastIndex, _ := lastIndexVal.(uint64) sagaCountVal, _ := saga.GetData("saga_count") sagaCount, _ := sagaCountVal.(int) activeSagasVal, _ := saga.GetData("active_sagas") activeSagas, _ := activeSagasVal.([]*storage.SagaTransaction) currentIndex, err := bs.walReader.GetCurrentIndex() if err != nil { return fmt.Errorf("failed to get current WAL index: %v", err) } lastFull := bs.findLastFullBackup() if lastFull == nil { return fmt.Errorf("no full backup found") } sagaState := make(map[string]interface{}) if sagaCount > 0 && bs.config.IncludeSagaState { sagaState["active_count"] = sagaCount sagaState["timestamp"] = time.Now().UnixMilli() if activeSagas != nil { sagaIDs := make([]string, 0, len(activeSagas)) sagaStatuses := make([]string, 0, len(activeSagas)) for _, s := range activeSagas { sagaIDs = append(sagaIDs, s.ID) sagaStatuses = append(sagaStatuses, s.Status) } sagaState["saga_ids"] = sagaIDs sagaState["saga_statuses"] = sagaStatuses } } backupData := struct { BackupID string `json:"backup_id"` Type string `json:"type"` StartTime int64 `json:"start_time"` WALStart uint64 `json:"wal_start"` WALEnd uint64 `json:"wal_end"` ParentID string `json:"parent_id"` Entries []WALEntry `json:"entries"` Metadata interface{} `json:"metadata,omitempty"` SagaState interface{} `json:"saga_state,omitempty"` SagaID string `json:"saga_id"` }{ BackupID: backupID, Type: "incremental", StartTime: startTime, WALStart: lastIndex, WALEnd: currentIndex, ParentID: lastFull.ID, Entries: entries, Metadata: map[string]interface{}{ "version": "1.0", "created_by": "backup_scheduler", "entries_count": len(entries), "parent_backup": lastFull.ID, "parent_time": lastFull.StartTime, "saga_active_count": sagaCount, "include_saga_state": bs.config.IncludeSagaState, "saga_id": sagaID, "backup_config": map[string]interface{}{ "backup_dir": bs.config.BackupDir, "compress_enabled": bs.config.CompressEnabled, "enable_incremental": bs.config.EnableIncremental, "retention_days": bs.config.RetentionDays, }, }, SagaState: sagaState, SagaID: sagaID, } data, err := json.Marshal(backupData) if err != nil { return fmt.Errorf("failed to marshal backup data: %v", err) } backupPath := fmt.Sprintf("inc/%s/%s.backup", time.Now().Format("2006-01-02"), backupID) if err := bs.storage.SaveBackup(backupPath, data); err != nil { return fmt.Errorf("failed to save incremental backup: %v", err) } saga.SetData("backup_path", backupPath) saga.SetData("backup_data", data) saga.SetData("backup_size", int64(len(data))) return nil }, func() error { backupPathVal, _ := saga.GetData("backup_path") if backupPath, ok := backupPathVal.(string); ok && backupPath != "" { bs.storage.DeleteBackup(backupPath) if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Compensating: deleted backup file %s", backupPath)) } } return nil }, map[string]interface{}{ "step": "create_backup_file", }) saga.AddStep("update_metadata", func() error { backupPathVal, _ := saga.GetData("backup_path") backupPath, _ := backupPathVal.(string) backupDataVal, _ := saga.GetData("backup_data") backupDataBytes, _ := backupDataVal.([]byte) backupSizeVal, _ := saga.GetData("backup_size") backupSize, _ := backupSizeVal.(int64) sagaCountVal, _ := saga.GetData("saga_count") sagaCount, _ := sagaCountVal.(int) if backupPath == "" { return fmt.Errorf("backup path not found") } currentIndex, err := bs.walReader.GetCurrentIndex() if err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to get current WAL index: %v", err)) } } else { if err := bs.walReader.SetLastBackupIndex(currentIndex); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to set last backup index: %v", err)) } } } hash := sha256.Sum256(backupDataBytes) checksum := hex.EncodeToString(hash[:]) endTime := time.Now().UnixMilli() lastFull := bs.findLastFullBackup() lastIndex := bs.walReader.GetLastBackupIndex() backupInfo := &BackupInfo{ ID: backupID, ScheduleID: scheduleID, Type: IncrementalBackup, StartTime: startTime, EndTime: endTime, Status: "completed", SizeBytes: backupSize, Path: backupPath, WALStart: lastIndex, WALEnd: currentIndex, ParentID: lastFull.ID, Checksum: checksum, SagaCount: sagaCount, SagaID: sagaID, } bs.mu.Lock() bs.backups[backupID] = backupInfo bs.mu.Unlock() if err := bs.saveBackupHistory(); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to save backup history: %v", err)) } } bs.updateScheduleLastRun(scheduleID, startTime) duration := (endTime - startTime) / 1000 if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Incremental backup %s completed in %d seconds, entries=%d, size=%d bytes, active_sagas=%d", backupID, duration, len(entries), backupSize, sagaCount)) } return nil }, func() error { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Compensating: rolling back metadata for backup %s", backupID)) } bs.mu.Lock() delete(bs.backups, backupID) bs.mu.Unlock() bs.saveBackupHistory() return nil }, map[string]interface{}{ "step": "update_metadata", }) if err := bs.sagaManager.Execute(saga); err != nil { if bs.logger != nil { bs.logger.Error(fmt.Sprintf("SAGA backup transaction failed: %v", err)) } return err } return nil } func (bs *BackupScheduler) findLastFullBackup() *BackupInfo { bs.mu.RLock() defer bs.mu.RUnlock() var lastFull *BackupInfo for _, backup := range bs.backups { if backup.Type == FullBackup && backup.Status == "completed" { if lastFull == nil || backup.EndTime > lastFull.EndTime { lastFull = backup } } } return lastFull } func (bs *BackupScheduler) updateScheduleLastRun(scheduleID string, timestamp int64) { bs.mu.Lock() defer bs.mu.Unlock() if schedule, exists := bs.schedules[scheduleID]; exists { schedule.LastRun = timestamp schedule.UpdatedAt = time.Now().UnixMilli() if err := bs.saveSchedules(); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to save schedules after update: %v", err)) } } } } func (bs *BackupScheduler) cleanupOldBackups(scheduleID string) { bs.mu.Lock() defer bs.mu.Unlock() schedule, exists := bs.schedules[scheduleID] if !exists { return } cutoffTime := time.Now().AddDate(0, 0, -schedule.RetentionDays).UnixMilli() toDelete := make([]string, 0) for id, backup := range bs.backups { if backup.ScheduleID == scheduleID && backup.StartTime < cutoffTime { toDelete = append(toDelete, id) if err := bs.storage.DeleteBackup(backup.Path); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to delete backup file %s: %v", backup.Path, err)) } } } } for _, id := range toDelete { delete(bs.backups, id) } if len(toDelete) > 0 { if err := bs.saveBackupHistory(); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to save backup history after cleanup: %v", err)) } } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Cleaned up %d old backups for schedule %s", len(toDelete), scheduleID)) } } } func (bs *BackupScheduler) ListBackups() []*BackupInfo { bs.mu.RLock() defer bs.mu.RUnlock() backups := make([]*BackupInfo, 0, len(bs.backups)) for _, b := range bs.backups { backups = append(backups, b) } sort.Slice(backups, func(i, j int) bool { return backups[i].StartTime > backups[j].StartTime }) return backups } func (bs *BackupScheduler) ListSchedules() []*BackupSchedule { bs.mu.RLock() defer bs.mu.RUnlock() schedules := make([]*BackupSchedule, 0, len(bs.schedules)) for _, s := range bs.schedules { schedules = append(schedules, s) } return schedules } func (bs *BackupScheduler) GetBackup(backupID string) *BackupInfo { bs.mu.RLock() defer bs.mu.RUnlock() return bs.backups[backupID] } func (bs *BackupScheduler) Restore(backupID string) error { if !bs.running.CompareAndSwap(false, true) { return fmt.Errorf("restore already in progress") } defer bs.running.Store(false) backup := bs.GetBackup(backupID) if backup == nil { return fmt.Errorf("backup not found: %s", backupID) } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Starting restore from backup %s", backupID)) } if !bs.storage.BackupExists(backup.Path) { return fmt.Errorf("backup file not found: %s", backup.Path) } data, err := bs.storage.LoadBackup(backup.Path) if err != nil { return fmt.Errorf("failed to load backup: %v", err) } hash := sha256.Sum256(data) checksum := hex.EncodeToString(hash[:]) if checksum != backup.Checksum { return fmt.Errorf("backup checksum mismatch, data may be corrupted") } if backup.Type == FullBackup { if bs.logger != nil { bs.logger.Info("Restoring from full backup...") } var fullData struct { BackupID string `json:"backup_id"` Type string `json:"type"` Entries []WALEntry `json:"entries"` Metadata interface{} `json:"metadata"` SagaState interface{} `json:"saga_state,omitempty"` } if err := json.Unmarshal(data, &fullData); err != nil { return fmt.Errorf("failed to parse full backup data: %v", err) } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Full backup contains %d WAL entries", len(fullData.Entries))) } if fullData.SagaState != nil && bs.config.IncludeSagaState { if bs.logger != nil { bs.logger.Info("Restoring SAGA state from backup...") } if sagaState, ok := fullData.SagaState.(map[string]interface{}); ok { if activeCount, ok := sagaState["active_count"].(float64); ok && activeCount > 0 { if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Found %d active SAGA transactions in backup", int(activeCount))) } } } } } else { if bs.logger != nil { bs.logger.Info("Restoring from incremental backup...") } if backup.ParentID == "" { return fmt.Errorf("incremental backup has no parent ID") } parentBackup := bs.GetBackup(backup.ParentID) if parentBackup == nil { return fmt.Errorf("parent backup %s not found", backup.ParentID) } if err := bs.Restore(backup.ParentID); err != nil { return fmt.Errorf("failed to restore parent backup: %v", err) } var incData struct { BackupID string `json:"backup_id"` Type string `json:"type"` WALStart uint64 `json:"wal_start"` WALEnd uint64 `json:"wal_end"` Entries []WALEntry `json:"entries"` SagaState interface{} `json:"saga_state,omitempty"` SagaID string `json:"saga_id,omitempty"` } if err := json.Unmarshal(data, &incData); err != nil { return fmt.Errorf("failed to parse incremental backup data: %v", err) } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Applying %d incremental changes (WAL %d -> %d)", len(incData.Entries), incData.WALStart, incData.WALEnd)) } if incData.SagaState != nil && bs.config.IncludeSagaState { if bs.logger != nil { bs.logger.Info("Restoring SAGA state from incremental backup...") } if sagaState, ok := incData.SagaState.(map[string]interface{}); ok { if activeCount, ok := sagaState["active_count"].(float64); ok && activeCount > 0 { if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Found %d active SAGA transactions in incremental backup", int(activeCount))) } if incData.SagaID != "" && bs.sagaManager != nil { if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Restoring SAGA transaction %s", incData.SagaID)) } } } } } } if bs.logger != nil { bs.logger.Info(fmt.Sprintf("Restore from backup %s completed", backupID)) } return nil } func (bs *BackupScheduler) Start() { bs.wg.Add(1) go bs.schedulerLoop() if bs.logger != nil { bs.logger.Info("Backup scheduler started") } } func (bs *BackupScheduler) schedulerLoop() { defer bs.wg.Done() ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { case <-bs.stopChan: return case <-ticker.C: bs.checkSchedules() } } } func (bs *BackupScheduler) checkSchedules() { bs.mu.RLock() schedules := make([]*BackupSchedule, 0, len(bs.schedules)) for _, s := range bs.schedules { if s.Enabled { schedules = append(schedules, s) } } bs.mu.RUnlock() now := time.Now() for _, schedule := range schedules { if schedule.NextRun == 0 || schedule.NextRun <= now.UnixMilli() { schedule.NextRun = now.Add(24 * time.Hour).UnixMilli() bs.mu.Lock() bs.schedules[schedule.ID] = schedule bs.mu.Unlock() if err := bs.saveSchedules(); err != nil { if bs.logger != nil { bs.logger.Warn(fmt.Sprintf("Failed to save schedule after update: %v", err)) } } scheduleID := schedule.ID backupType := schedule.Type go func() { if backupType == FullBackup { bs.performFullBackup(scheduleID) } else { bs.performIncrementalBackup(scheduleID) } }() } } } func (bs *BackupScheduler) Stop() { close(bs.stopChan) bs.wg.Wait() if bs.logger != nil { bs.logger.Info("Backup scheduler stopped") } } func (bs *BackupScheduler) GetStats() map[string]interface{} { bs.mu.RLock() defer bs.mu.RUnlock() fullCount := 0 incCount := 0 totalSize := int64(0) totalSaga := 0 for _, b := range bs.backups { if b.Type == FullBackup { fullCount++ } else { incCount++ } totalSize += b.SizeBytes totalSaga += b.SagaCount } return map[string]interface{}{ "total_backups": len(bs.backups), "full_backups": fullCount, "incremental_backups": incCount, "total_size_bytes": totalSize, "schedules": len(bs.schedules), "is_running": bs.running.Load(), "backup_dir": bs.config.BackupDir, "compress_enabled": bs.config.CompressEnabled, "incremental_enabled": bs.config.EnableIncremental, "include_saga_state": bs.config.IncludeSagaState, "total_saga_transactions": totalSaga, "retention_days": bs.config.RetentionDays, "max_concurrent": bs.config.MaxConcurrent, } }