467 lines
14 KiB
Go
467 lines
14 KiB
Go
|
|
/*
|
|||
|
|
* 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/storage/persistence.go
|
|||
|
|
// Назначение: Персистентное хранение данных на диске с поддержкой checkpoint и recovery
|
|||
|
|
|
|||
|
|
package storage
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"compress/gzip"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"sort"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"sync/atomic"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// PersistenceConfig конфигурация персистентного хранения
|
|||
|
|
type PersistenceConfig struct {
|
|||
|
|
DataDir string `json:"data_dir"`
|
|||
|
|
CheckpointInterval time.Duration `json:"checkpoint_interval"`
|
|||
|
|
MaxCheckpoints int `json:"max_checkpoints"`
|
|||
|
|
CompressEnabled bool `json:"compress_enabled"`
|
|||
|
|
SyncWrites bool `json:"sync_writes"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DefaultPersistenceConfig возвращает конфигурацию по умолчанию
|
|||
|
|
func DefaultPersistenceConfig() *PersistenceConfig {
|
|||
|
|
return &PersistenceConfig{
|
|||
|
|
DataDir: "futriis_data",
|
|||
|
|
CheckpointInterval: 5 * time.Minute,
|
|||
|
|
MaxCheckpoints: 10,
|
|||
|
|
CompressEnabled: true,
|
|||
|
|
SyncWrites: true,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DatabaseSnapshot представляет снапшот базы данных
|
|||
|
|
type DatabaseSnapshot struct {
|
|||
|
|
Name string `json:"name"`
|
|||
|
|
Collections map[string]interface{} `json:"collections"`
|
|||
|
|
CreatedAt int64 `json:"created_at"`
|
|||
|
|
Version uint64 `json:"version"`
|
|||
|
|
Checksum string `json:"checksum"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PersistenceManager управляет персистентным хранением
|
|||
|
|
type PersistenceManager struct {
|
|||
|
|
config *PersistenceConfig
|
|||
|
|
storage *Storage
|
|||
|
|
logger LoggerInterface
|
|||
|
|
mu sync.RWMutex
|
|||
|
|
stopChan chan struct{}
|
|||
|
|
wg sync.WaitGroup
|
|||
|
|
lastCheckpoint int64
|
|||
|
|
checkpointID atomic.Uint64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewPersistenceManager создаёт новый менеджер персистентности
|
|||
|
|
func NewPersistenceManager(config *PersistenceConfig, storage *Storage, logger LoggerInterface) *PersistenceManager {
|
|||
|
|
if config == nil {
|
|||
|
|
config = DefaultPersistenceConfig()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pm := &PersistenceManager{
|
|||
|
|
config: config,
|
|||
|
|
storage: storage,
|
|||
|
|
logger: logger,
|
|||
|
|
stopChan: make(chan struct{}),
|
|||
|
|
lastCheckpoint: time.Now().UnixMilli(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Создаём директорию для данных
|
|||
|
|
if err := os.MkdirAll(config.DataDir, 0755); err != nil {
|
|||
|
|
if logger != nil {
|
|||
|
|
logger.Error(fmt.Sprintf("Failed to create data directory: %v", err))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return pm
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Start запускает фоновое сохранение чекпоинтов
|
|||
|
|
func (pm *PersistenceManager) Start() {
|
|||
|
|
pm.wg.Add(1)
|
|||
|
|
go pm.checkpointLoop()
|
|||
|
|
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Info(fmt.Sprintf("Persistence manager started, data dir: %s", pm.config.DataDir))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stop останавливает менеджер
|
|||
|
|
func (pm *PersistenceManager) Stop() {
|
|||
|
|
close(pm.stopChan)
|
|||
|
|
pm.wg.Wait()
|
|||
|
|
|
|||
|
|
// Финальное сохранение
|
|||
|
|
pm.SaveAll()
|
|||
|
|
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Info("Persistence manager stopped")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// checkpointLoop периодически создаёт чекпоинты
|
|||
|
|
func (pm *PersistenceManager) checkpointLoop() {
|
|||
|
|
defer pm.wg.Done()
|
|||
|
|
|
|||
|
|
ticker := time.NewTicker(pm.config.CheckpointInterval)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
select {
|
|||
|
|
case <-pm.stopChan:
|
|||
|
|
return
|
|||
|
|
case <-ticker.C:
|
|||
|
|
if err := pm.SaveAll(); err != nil {
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Error(fmt.Sprintf("Failed to save checkpoint: %v", err))
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
pm.cleanupOldCheckpoints()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SaveDatabase сохраняет базу данных на диск
|
|||
|
|
func (pm *PersistenceManager) SaveDatabase(dbName string) error {
|
|||
|
|
pm.mu.Lock()
|
|||
|
|
defer pm.mu.Unlock()
|
|||
|
|
|
|||
|
|
db, err := pm.storage.GetDatabase(dbName)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("database not found: %s", dbName)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Собираем данные всех коллекций
|
|||
|
|
snapshot := &DatabaseSnapshot{
|
|||
|
|
Name: dbName,
|
|||
|
|
Collections: make(map[string]interface{}),
|
|||
|
|
CreatedAt: time.Now().UnixMilli(),
|
|||
|
|
Version: pm.checkpointID.Add(1),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, collName := range db.ListCollections() {
|
|||
|
|
coll, err := db.GetCollection(collName)
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
docs := coll.GetAllDocuments()
|
|||
|
|
collData := make([]map[string]interface{}, 0, len(docs))
|
|||
|
|
for _, doc := range docs {
|
|||
|
|
collData = append(collData, map[string]interface{}{
|
|||
|
|
"_id": doc.ID,
|
|||
|
|
"fields": doc.GetFields(),
|
|||
|
|
"created_at": doc.CreatedAt,
|
|||
|
|
"updated_at": doc.UpdatedAt,
|
|||
|
|
"version": doc.Version,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
snapshot.Collections[collName] = collData
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Сериализуем и сохраняем
|
|||
|
|
data, err := json.Marshal(snapshot)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Опционально сжимаем
|
|||
|
|
if pm.config.CompressEnabled {
|
|||
|
|
data, err = pm.compress(data)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Сохраняем в файл
|
|||
|
|
filename := pm.getSnapshotFilename(dbName, snapshot.Version)
|
|||
|
|
if err := os.WriteFile(filename, data, 0644); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Синхронизируем с диском если нужно
|
|||
|
|
if pm.config.SyncWrites {
|
|||
|
|
if f, err := os.OpenFile(filename, os.O_RDWR, 0644); err == nil {
|
|||
|
|
RealFsync(f)
|
|||
|
|
f.Close()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Info(fmt.Sprintf("Saved database %s checkpoint %d (%d bytes)", dbName, snapshot.Version, len(data)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SaveAll сохраняет все базы данных
|
|||
|
|
func (pm *PersistenceManager) SaveAll() error {
|
|||
|
|
databases := pm.storage.ListDatabases()
|
|||
|
|
|
|||
|
|
var lastErr error
|
|||
|
|
for _, dbName := range databases {
|
|||
|
|
if err := pm.SaveDatabase(dbName); err != nil {
|
|||
|
|
lastErr = err
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Error(fmt.Sprintf("Failed to save database %s: %v", dbName, err))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return lastErr
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// LoadDatabase загружает базу данных с диска
|
|||
|
|
func (pm *PersistenceManager) LoadDatabase(dbName string) error {
|
|||
|
|
pm.mu.Lock()
|
|||
|
|
defer pm.mu.Unlock()
|
|||
|
|
|
|||
|
|
// Находим последний чекпоинт
|
|||
|
|
snapshot, err := pm.findLatestSnapshot(dbName)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if snapshot == nil {
|
|||
|
|
return fmt.Errorf("no snapshot found for database: %s", dbName)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Восстанавливаем базу данных
|
|||
|
|
if !pm.storage.ExistsDatabase(dbName) {
|
|||
|
|
if err := pm.storage.CreateDatabase(dbName); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db, err := pm.storage.GetDatabase(dbName)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Восстанавливаем коллекции
|
|||
|
|
for collName, collDataRaw := range snapshot.Collections {
|
|||
|
|
collData, ok := collDataRaw.([]interface{})
|
|||
|
|
if !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if _, err := db.GetCollection(collName); err != nil {
|
|||
|
|
if err := db.CreateCollection(collName); err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
coll, err := db.GetCollection(collName)
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, docRaw := range collData {
|
|||
|
|
docMap, ok := docRaw.(map[string]interface{})
|
|||
|
|
if !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
docID, ok := docMap["_id"].(string)
|
|||
|
|
if !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
doc := NewDocumentWithID(docID)
|
|||
|
|
|
|||
|
|
if fields, ok := docMap["fields"].(map[string]interface{}); ok {
|
|||
|
|
for k, v := range fields {
|
|||
|
|
doc.SetField(k, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if createdAt, ok := docMap["created_at"].(float64); ok {
|
|||
|
|
doc.CreatedAt = int64(createdAt)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if updatedAt, ok := docMap["updated_at"].(float64); ok {
|
|||
|
|
doc.UpdatedAt = int64(updatedAt)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if version, ok := docMap["version"].(float64); ok {
|
|||
|
|
doc.Version = uint64(version)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := coll.Insert(doc); err != nil {
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Error(fmt.Sprintf("Failed to restore document %s: %v", docID, err))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Info(fmt.Sprintf("Loaded database %s from snapshot (version %d)", dbName, snapshot.Version))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// LoadAll загружает все базы данных с диска
|
|||
|
|
func (pm *PersistenceManager) LoadAll() error {
|
|||
|
|
files, err := filepath.Glob(filepath.Join(pm.config.DataDir, "snapshot_*.json*"))
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
databases := make(map[string]bool)
|
|||
|
|
for _, file := range files {
|
|||
|
|
base := filepath.Base(file)
|
|||
|
|
// Извлекаем имя базы данных из имени файла
|
|||
|
|
// Формат: snapshot_{dbname}_{version}.json.gz
|
|||
|
|
parts := strings.Split(base, "_")
|
|||
|
|
if len(parts) >= 2 {
|
|||
|
|
dbName := parts[1]
|
|||
|
|
databases[dbName] = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for dbName := range databases {
|
|||
|
|
if err := pm.LoadDatabase(dbName); err != nil {
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Error(fmt.Sprintf("Failed to load database %s: %v", dbName, err))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// getSnapshotFilename возвращает имя файла для снапшота
|
|||
|
|
func (pm *PersistenceManager) getSnapshotFilename(dbName string, version uint64) string {
|
|||
|
|
filename := fmt.Sprintf("snapshot_%s_%d.json", dbName, version)
|
|||
|
|
if pm.config.CompressEnabled {
|
|||
|
|
filename += ".gz"
|
|||
|
|
}
|
|||
|
|
return filepath.Join(pm.config.DataDir, filename)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// findLatestSnapshot находит последний снапшот базы данных
|
|||
|
|
func (pm *PersistenceManager) findLatestSnapshot(dbName string) (*DatabaseSnapshot, error) {
|
|||
|
|
pattern := filepath.Join(pm.config.DataDir, fmt.Sprintf("snapshot_%s_*.json*", dbName))
|
|||
|
|
files, err := filepath.Glob(pattern)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(files) == 0 {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Сортируем по времени создания (новейшие первыми)
|
|||
|
|
sort.Slice(files, func(i, j int) bool {
|
|||
|
|
infoI, _ := os.Stat(files[i])
|
|||
|
|
infoJ, _ := os.Stat(files[j])
|
|||
|
|
if infoI == nil || infoJ == nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return infoI.ModTime().After(infoJ.ModTime())
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Читаем последний снапшот
|
|||
|
|
data, err := os.ReadFile(files[0])
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if pm.config.CompressEnabled && strings.HasSuffix(files[0], ".gz") {
|
|||
|
|
data, err = pm.decompress(data)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var snapshot DatabaseSnapshot
|
|||
|
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &snapshot, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// compress сжимает данные с помощью gzip
|
|||
|
|
func (pm *PersistenceManager) compress(data []byte) ([]byte, error) {
|
|||
|
|
var buf bytes.Buffer
|
|||
|
|
w := gzip.NewWriter(&buf)
|
|||
|
|
|
|||
|
|
if _, err := w.Write(data); err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to write compressed data: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := w.Close(); err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to close gzip writer: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return buf.Bytes(), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// decompress разжимает данные с помощью gzip
|
|||
|
|
func (pm *PersistenceManager) decompress(data []byte) ([]byte, error) {
|
|||
|
|
reader, err := gzip.NewReader(bytes.NewReader(data))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to create gzip reader: %v", err)
|
|||
|
|
}
|
|||
|
|
defer reader.Close()
|
|||
|
|
|
|||
|
|
var buf bytes.Buffer
|
|||
|
|
if _, err := buf.ReadFrom(reader); err != nil {
|
|||
|
|
return nil, fmt.Errorf("failed to decompress data: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return buf.Bytes(), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// cleanupOldCheckpoints удаляет старые чекпоинты
|
|||
|
|
func (pm *PersistenceManager) cleanupOldCheckpoints() {
|
|||
|
|
pattern := filepath.Join(pm.config.DataDir, "snapshot_*.json*")
|
|||
|
|
files, err := filepath.Glob(pattern)
|
|||
|
|
if err != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(files) <= pm.config.MaxCheckpoints {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Сортируем по времени модификации
|
|||
|
|
sort.Slice(files, func(i, j int) bool {
|
|||
|
|
infoI, _ := os.Stat(files[i])
|
|||
|
|
infoJ, _ := os.Stat(files[j])
|
|||
|
|
if infoI == nil || infoJ == nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return infoI.ModTime().Before(infoJ.ModTime())
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Удаляем старые
|
|||
|
|
toDelete := files[:len(files)-pm.config.MaxCheckpoints]
|
|||
|
|
for _, f := range toDelete {
|
|||
|
|
os.Remove(f)
|
|||
|
|
if pm.logger != nil {
|
|||
|
|
pm.logger.Debug(fmt.Sprintf("Removed old checkpoint: %s", f))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|