Files
futriix/internal/backup/backup_scheduler.go

715 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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
*/
// Файл: internal/backup/backup_scheduler.go
// Назначение: Планировщик бэкапов и инкрементальные бэкапы на основе WAL
package backup
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
)
// BackupType тип бэкапа
type BackupType string
const (
FullBackup BackupType = "full"
IncrementalBackup BackupType = "incremental"
)
// BackupSchedule расписание бэкапов
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"`
}
// BackupInfo информация о бэкапе
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"`
}
// BackupScheduler планировщик бэкапов
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
}
// WALReader интерфейс для чтения WAL
type WALReader interface {
ReadSince(index uint64) ([]WALEntry, error)
GetCurrentIndex() (uint64, error)
GetLastBackupIndex() uint64
SetLastBackupIndex(index uint64) error
}
// WALEntry запись WAL
type WALEntry struct {
Index uint64 `json:"index"`
Term uint64 `json:"term"`
Type string `json:"type"`
Data []byte `json:"data"`
Timestamp int64 `json:"timestamp"`
}
// BackupStorage интерфейс для хранения бэкапов
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
}
// LoggerInterface интерфейс для логирования
type LoggerInterface interface {
Info(msg string)
Warn(msg string)
Error(msg string)
Debug(msg string)
}
// FileBackupStorage реализация BackupStorage для файловой системы
type FileBackupStorage struct {
backupDir string
}
// NewFileBackupStorage создаёт новое файловое хранилище бэкапов
func NewFileBackupStorage(backupDir string) (*FileBackupStorage, error) {
if err := os.MkdirAll(backupDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create backup dir: %v", err)
}
return &FileBackupStorage{backupDir: backupDir}, nil
}
func (fbs *FileBackupStorage) SaveBackup(path string, data []byte) error {
fullPath := filepath.Join(fbs.backupDir, path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
return os.WriteFile(fullPath, data, 0644)
}
func (fbs *FileBackupStorage) LoadBackup(path string) ([]byte, error) {
fullPath := filepath.Join(fbs.backupDir, path)
return os.ReadFile(fullPath)
}
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 {
names = append(names, e.Name())
}
return names, nil
}
func (fbs *FileBackupStorage) DeleteBackup(path string) error {
fullPath := filepath.Join(fbs.backupDir, path)
return os.RemoveAll(fullPath)
}
func (fbs *FileBackupStorage) BackupExists(path string) bool {
fullPath := filepath.Join(fbs.backupDir, path)
_, err := os.Stat(fullPath)
return err == nil
}
// BackupSchedulerConfig конфигурация планировщика
type BackupSchedulerConfig struct {
BackupDir string `json:"backup_dir"`
MaxConcurrent int `json:"max_concurrent"`
CompressEnabled bool `json:"compress_enabled"`
CompressionLevel int `json:"compression_level"`
}
// DefaultBackupSchedulerConfig возвращает конфигурацию по умолчанию
func DefaultBackupSchedulerConfig() *BackupSchedulerConfig {
return &BackupSchedulerConfig{
BackupDir: "backups",
MaxConcurrent: 1,
CompressEnabled: true,
CompressionLevel: 6,
}
}
// NewBackupScheduler создаёт новый планировщик бэкапов
func NewBackupScheduler(cfg *BackupSchedulerConfig, walReader WALReader, logger LoggerInterface) (*BackupScheduler, error) {
if cfg == nil {
cfg = DefaultBackupSchedulerConfig()
}
storage, err := NewFileBackupStorage(cfg.BackupDir)
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{}),
}
// Загружаем существующие расписания
bs.loadSchedules()
// Загружаем историю бэкапов
bs.loadBackupHistory()
return bs, nil
}
// loadSchedules загружает расписания из хранилища
func (bs *BackupScheduler) loadSchedules() {
schedulesPath := filepath.Join(bs.storage.(*FileBackupStorage).backupDir, "schedules.json")
data, err := bs.storage.LoadBackup("schedules.json")
if err != nil {
if !os.IsNotExist(err) {
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 {
bs.logger.Warn(fmt.Sprintf("Failed to parse schedules: %v", err))
return
}
bs.mu.Lock()
bs.schedules = schedules
bs.mu.Unlock()
}
// saveSchedules сохраняет расписания
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)
}
// loadBackupHistory загружает историю бэкапов
func (bs *BackupScheduler) loadBackupHistory() {
historyPath := filepath.Join(bs.storage.(*FileBackupStorage).backupDir, "backups.json")
data, err := bs.storage.LoadBackup("backups.json")
if err != nil {
if !os.IsNotExist(err) {
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 {
bs.logger.Warn(fmt.Sprintf("Failed to parse backup history: %v", err))
return
}
bs.mu.Lock()
bs.backups = backups
bs.mu.Unlock()
}
// saveBackupHistory сохраняет историю бэкапов
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)
}
// AddSchedule добавляет расписание бэкапов
func (bs *BackupScheduler) AddSchedule(name, cronExpr string, backupType BackupType, retentionDays int) (*BackupSchedule, error) {
bs.mu.Lock()
defer bs.mu.Unlock()
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, err
}
bs.logger.Info(fmt.Sprintf("Added backup schedule: %s (%s) with cron %s", name, backupType, cronExpr))
return schedule, nil
}
// RemoveSchedule удаляет расписание
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)
return bs.saveSchedules()
}
// RunNow запускает бэкап немедленно
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 schedule.Type == FullBackup {
return bs.performFullBackup(scheduleID)
}
return bs.performIncrementalBackup(scheduleID)
}
// performFullBackup выполняет полный бэкап
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", bs.backupCounter.Add(1))
bs.logger.Info(fmt.Sprintf("Starting full backup %s", backupID))
// Получаем текущую позицию WAL
walIndex, err := bs.walReader.GetCurrentIndex()
if err != nil {
return fmt.Errorf("failed to get WAL index: %v", err)
}
// Сохраняем последний индекс для инкрементальных бэкапов
if err := bs.walReader.SetLastBackupIndex(walIndex); err != nil {
bs.logger.Warn(fmt.Sprintf("Failed to set last backup index: %v", err))
}
// Здесь выполняется реальный бэкап данных
backupPath := fmt.Sprintf("full/%s/%s.backup",
time.Now().Format("2006-01-02"), backupID)
// Симуляция бэкапа (в реальности здесь будет копирование данных)
backupData := []byte(fmt.Sprintf(`{"backup_id":"%s","type":"full","timestamp":%d,"wal_index":%d}`,
backupID, startTime, walIndex))
if err := bs.storage.SaveBackup(backupPath, backupData); err != nil {
return fmt.Errorf("failed to save backup: %v", err)
}
// Вычисляем контрольную сумму
hash := sha256.Sum256(backupData)
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(backupData)),
Path: backupPath,
WALStart: 0,
WALEnd: walIndex,
Checksum: checksum,
}
bs.mu.Lock()
bs.backups[backupID] = backupInfo
bs.mu.Unlock()
bs.saveBackupHistory()
bs.updateScheduleLastRun(scheduleID, startTime)
bs.cleanupOldBackups(scheduleID)
duration := (endTime - startTime) / 1000
bs.logger.Info(fmt.Sprintf("Full backup %s completed in %d seconds, size=%d bytes",
backupID, duration, backupInfo.SizeBytes))
return nil
}
// performIncrementalBackup выполняет инкрементальный бэкап на основе WAL
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", bs.backupCounter.Add(1))
// Находим последний полный бэкап
lastFullBackup := bs.findLastFullBackup()
if lastFullBackup == nil {
bs.logger.Info("No full backup found, performing full backup instead")
return bs.performFullBackup(scheduleID)
}
// Получаем WAL записи с момента последнего бэкапа
lastIndex := bs.walReader.GetLastBackupIndex()
if lastIndex == 0 {
lastIndex = lastFullBackup.WALEnd
}
walEntries, err := bs.walReader.ReadSince(lastIndex)
if err != nil {
return fmt.Errorf("failed to read WAL: %v", err)
}
if len(walEntries) == 0 {
bs.logger.Info("No new WAL entries since last backup")
return nil
}
currentIndex, _ := bs.walReader.GetCurrentIndex()
// Сохраняем инкрементальный бэкап
backupPath := fmt.Sprintf("inc/%s/%s.backup",
time.Now().Format("2006-01-02"), backupID)
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"`
}{
BackupID: backupID,
Type: "incremental",
StartTime: startTime,
WALStart: lastIndex,
WALEnd: currentIndex,
ParentID: lastFullBackup.ID,
Entries: walEntries,
}
data, err := json.Marshal(backupData)
if err != nil {
return fmt.Errorf("failed to marshal backup data: %v", err)
}
if err := bs.storage.SaveBackup(backupPath, data); err != nil {
return fmt.Errorf("failed to save incremental backup: %v", err)
}
// Обновляем последний индекс
if err := bs.walReader.SetLastBackupIndex(currentIndex); err != nil {
bs.logger.Warn(fmt.Sprintf("Failed to set last backup index: %v", err))
}
hash := sha256.Sum256(data)
checksum := hex.EncodeToString(hash[:])
endTime := time.Now().UnixMilli()
backupInfo := &BackupInfo{
ID: backupID,
ScheduleID: scheduleID,
Type: IncrementalBackup,
StartTime: startTime,
EndTime: endTime,
Status: "completed",
SizeBytes: int64(len(data)),
Path: backupPath,
WALStart: lastIndex,
WALEnd: currentIndex,
ParentID: lastFullBackup.ID,
Checksum: checksum,
}
bs.mu.Lock()
bs.backups[backupID] = backupInfo
bs.mu.Unlock()
bs.saveBackupHistory()
bs.updateScheduleLastRun(scheduleID, startTime)
duration := (endTime - startTime) / 1000
bs.logger.Info(fmt.Sprintf("Incremental backup %s completed in %d seconds, %d entries, size=%d bytes",
backupID, duration, len(walEntries), backupInfo.SizeBytes))
return nil
}
// findLastFullBackup находит последний успешный полный бэкап
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
}
// updateScheduleLastRun обновляет время последнего запуска расписания
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()
bs.saveSchedules()
}
}
// cleanupOldBackups удаляет старые бэкапы
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)
// Удаляем файл бэкапа
bs.storage.DeleteBackup(backup.Path)
}
}
for _, id := range toDelete {
delete(bs.backups, id)
}
if len(toDelete) > 0 {
bs.saveBackupHistory()
bs.logger.Info(fmt.Sprintf("Cleaned up %d old backups for schedule %s", len(toDelete), scheduleID))
}
}
// ListBackups возвращает список бэкапов
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
}
// ListSchedules возвращает список расписаний
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
}
// GetBackup возвращает информацию о бэкапе
func (bs *BackupScheduler) GetBackup(backupID string) *BackupInfo {
bs.mu.RLock()
defer bs.mu.RUnlock()
return bs.backups[backupID]
}
// Restore восстанавливает данные из бэкапа
func (bs *BackupScheduler) Restore(backupID string) error {
backup := bs.GetBackup(backupID)
if backup == nil {
return fmt.Errorf("backup not found: %s", backupID)
}
bs.logger.Info(fmt.Sprintf("Starting restore from backup %s", backupID))
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 {
// Восстановление из полного бэкапа
bs.logger.Info("Restoring from full backup...")
// Здесь реальное восстановление
} else {
// Восстановление из инкрементального бэкапа
// Сначала восстанавливаем полный бэкап-родитель
if backup.ParentID != "" {
if err := bs.Restore(backup.ParentID); err != nil {
return fmt.Errorf("failed to restore parent backup: %v", err)
}
}
bs.logger.Info("Applying incremental changes...")
// Здесь применяем инкрементальные изменения
}
bs.logger.Info(fmt.Sprintf("Restore from backup %s completed", backupID))
return nil
}
// Start запускает планировщик
func (bs *BackupScheduler) Start() {
bs.wg.Add(1)
go bs.schedulerLoop()
bs.logger.Info("Backup scheduler started")
}
// schedulerLoop основной цикл планировщика
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()
}
}
}
// 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 {
// Простая проверка расписания (в реальности используется cron-парсер)
if schedule.NextRun == 0 || schedule.NextRun <= now.UnixMilli() {
// Вычисляем следующее время (упрощённо - через 24 часа)
schedule.NextRun = now.Add(24 * time.Hour).UnixMilli()
bs.mu.Lock()
bs.schedules[schedule.ID] = schedule
bs.mu.Unlock()
bs.saveSchedules()
// Запускаем бэкап в отдельной горутине
scheduleID := schedule.ID
backupType := schedule.Type
go func() {
if backupType == FullBackup {
bs.performFullBackup(scheduleID)
} else {
bs.performIncrementalBackup(scheduleID)
}
}()
}
}
}
// Stop останавливает планировщик
func (bs *BackupScheduler) Stop() {
close(bs.stopChan)
bs.wg.Wait()
bs.logger.Info("Backup scheduler stopped")
}