Files
futriix/internal/storage/persistence.go
2026-06-10 23:16:57 +03:00

467 lines
14 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/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))
}
}
}