mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
387 lines
8.8 KiB
Go
387 lines
8.8 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package backup provides database backup and restore functionality.
|
|
// It supports incremental backups using ZapDB's native backup mechanism
|
|
// with optional zstd compression.
|
|
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-json-experiment/json"
|
|
"github.com/go-json-experiment/json/jsontext"
|
|
"github.com/klauspost/compress/zstd"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
const (
|
|
// DefaultChunkSize is the default size of backup chunks (1MB).
|
|
DefaultChunkSize = 1 << 20
|
|
|
|
// MetadataFileName is the name of the backup metadata file.
|
|
MetadataFileName = "backup_metadata.json"
|
|
)
|
|
|
|
var (
|
|
// ErrDatabaseClosed is returned when the database is closed.
|
|
ErrDatabaseClosed = errors.New("database is closed")
|
|
|
|
// ErrBackupInProgress is returned when a backup is already in progress.
|
|
ErrBackupInProgress = errors.New("backup already in progress")
|
|
|
|
// ErrRestoreInProgress is returned when a restore is already in progress.
|
|
ErrRestoreInProgress = errors.New("restore already in progress")
|
|
)
|
|
|
|
// Metadata stores information about the last backup.
|
|
type Metadata struct {
|
|
LastVersion uint64 `json:"lastVersion"`
|
|
LastBackupTime time.Time `json:"lastBackupTime"`
|
|
LastBackupSize uint64 `json:"lastBackupSize"`
|
|
Incremental bool `json:"incremental"`
|
|
}
|
|
|
|
// Config configures the backup service.
|
|
type Config struct {
|
|
// DB is the database to backup/restore.
|
|
DB database.Database
|
|
// MetadataDir is the directory to store backup metadata.
|
|
MetadataDir string
|
|
// ChunkSize is the size of backup chunks in bytes.
|
|
ChunkSize int
|
|
// Log is the logger.
|
|
Log log.Logger
|
|
}
|
|
|
|
// Service provides backup and restore operations.
|
|
type Service struct {
|
|
db database.Database
|
|
metadataDir string
|
|
chunkSize int
|
|
log log.Logger
|
|
|
|
mu sync.Mutex
|
|
backupActive bool
|
|
restoreActive bool
|
|
currentMetadata *Metadata
|
|
}
|
|
|
|
// New creates a new backup service.
|
|
func New(cfg Config) (*Service, error) {
|
|
if cfg.DB == nil {
|
|
return nil, errors.New("database is required")
|
|
}
|
|
if cfg.MetadataDir == "" {
|
|
return nil, errors.New("metadata directory is required")
|
|
}
|
|
if cfg.ChunkSize <= 0 {
|
|
cfg.ChunkSize = DefaultChunkSize
|
|
}
|
|
|
|
s := &Service{
|
|
db: cfg.DB,
|
|
metadataDir: cfg.MetadataDir,
|
|
chunkSize: cfg.ChunkSize,
|
|
log: cfg.Log,
|
|
}
|
|
|
|
// Load existing metadata
|
|
if err := s.loadMetadata(); err != nil && !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("failed to load backup metadata: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Backup performs a database backup, writing to the provided writer.
|
|
// since: 0 for full backup, or the version from the last backup for incremental.
|
|
// compress: whether to compress the output with zstd.
|
|
// Returns the new version number for future incremental backups.
|
|
func (s *Service) Backup(w io.Writer, since uint64, compress bool) (uint64, error) {
|
|
s.mu.Lock()
|
|
if s.backupActive {
|
|
s.mu.Unlock()
|
|
return 0, ErrBackupInProgress
|
|
}
|
|
s.backupActive = true
|
|
s.mu.Unlock()
|
|
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.backupActive = false
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
var writer io.Writer = w
|
|
var encoder *zstd.Encoder
|
|
var err error
|
|
|
|
if compress {
|
|
encoder, err = zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create zstd encoder: %w", err)
|
|
}
|
|
defer encoder.Close()
|
|
writer = encoder
|
|
}
|
|
|
|
// Perform the backup
|
|
version, err := s.db.Backup(writer, since)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("backup failed: %w", err)
|
|
}
|
|
|
|
// Ensure compressed data is flushed
|
|
if encoder != nil {
|
|
if err := encoder.Close(); err != nil {
|
|
return 0, fmt.Errorf("failed to close zstd encoder: %w", err)
|
|
}
|
|
}
|
|
|
|
// Update metadata
|
|
s.mu.Lock()
|
|
s.currentMetadata = &Metadata{
|
|
LastVersion: version,
|
|
LastBackupTime: time.Now(),
|
|
Incremental: since > 0,
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if err := s.saveMetadata(); err != nil {
|
|
s.log.Warn("failed to save backup metadata", "error", err)
|
|
}
|
|
|
|
s.log.Info("backup completed",
|
|
"version", version,
|
|
"since", since,
|
|
"incremental", since > 0,
|
|
)
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// BackupToFile performs a backup to a file path.
|
|
func (s *Service) BackupToFile(path string, since uint64, compress bool) (uint64, error) {
|
|
// Ensure parent directory exists
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return 0, fmt.Errorf("failed to create backup directory: %w", err)
|
|
}
|
|
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create backup file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
version, err := s.Backup(f, since, compress)
|
|
if err != nil {
|
|
os.Remove(path) // Clean up on failure
|
|
return 0, err
|
|
}
|
|
|
|
// Get file size for metadata
|
|
info, err := f.Stat()
|
|
if err == nil {
|
|
s.mu.Lock()
|
|
if s.currentMetadata != nil {
|
|
s.currentMetadata.LastBackupSize = uint64(info.Size())
|
|
}
|
|
s.mu.Unlock()
|
|
s.saveMetadata()
|
|
}
|
|
|
|
return version, nil
|
|
}
|
|
|
|
// Restore restores the database from the provided reader.
|
|
// The reader should contain backup data (optionally zstd compressed).
|
|
func (s *Service) Restore(r io.Reader, compressed bool) error {
|
|
s.mu.Lock()
|
|
if s.restoreActive {
|
|
s.mu.Unlock()
|
|
return ErrRestoreInProgress
|
|
}
|
|
s.restoreActive = true
|
|
s.mu.Unlock()
|
|
|
|
defer func() {
|
|
s.mu.Lock()
|
|
s.restoreActive = false
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
var reader io.Reader = r
|
|
|
|
if compressed {
|
|
decoder, err := zstd.NewReader(r)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create zstd decoder: %w", err)
|
|
}
|
|
defer decoder.Close()
|
|
reader = decoder
|
|
}
|
|
|
|
if err := s.db.Load(reader); err != nil {
|
|
return fmt.Errorf("restore failed: %w", err)
|
|
}
|
|
|
|
s.log.Info("restore completed")
|
|
return nil
|
|
}
|
|
|
|
// RestoreFromFile restores the database from a file path.
|
|
func (s *Service) RestoreFromFile(path string, compressed bool) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open backup file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
return s.Restore(f, compressed)
|
|
}
|
|
|
|
// GetMetadata returns the current backup metadata.
|
|
func (s *Service) GetMetadata() *Metadata {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.currentMetadata == nil {
|
|
return &Metadata{}
|
|
}
|
|
|
|
// Return a copy
|
|
return &Metadata{
|
|
LastVersion: s.currentMetadata.LastVersion,
|
|
LastBackupTime: s.currentMetadata.LastBackupTime,
|
|
LastBackupSize: s.currentMetadata.LastBackupSize,
|
|
Incremental: s.currentMetadata.Incremental,
|
|
}
|
|
}
|
|
|
|
// GetLastVersion returns the version of the last backup.
|
|
// Returns 0 if no backup has been performed.
|
|
func (s *Service) GetLastVersion() uint64 {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.currentMetadata == nil {
|
|
return 0
|
|
}
|
|
return s.currentMetadata.LastVersion
|
|
}
|
|
|
|
func (s *Service) metadataPath() string {
|
|
return filepath.Join(s.metadataDir, MetadataFileName)
|
|
}
|
|
|
|
func (s *Service) loadMetadata() error {
|
|
data, err := os.ReadFile(s.metadataPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var meta Metadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return fmt.Errorf("failed to parse metadata: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.currentMetadata = &meta
|
|
s.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) saveMetadata() error {
|
|
s.mu.Lock()
|
|
meta := s.currentMetadata
|
|
s.mu.Unlock()
|
|
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
|
|
// Ensure directory exists
|
|
if err := os.MkdirAll(s.metadataDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create metadata directory: %w", err)
|
|
}
|
|
|
|
data, err := json.Marshal(meta, jsontext.WithIndent(" "))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(s.metadataPath(), data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BackupChunked performs a backup and returns data in chunks.
|
|
// This is useful for streaming backups over gRPC.
|
|
func (s *Service) BackupChunked(since uint64, compress bool) (<-chan []byte, <-chan error, <-chan uint64) {
|
|
dataCh := make(chan []byte, 10)
|
|
errCh := make(chan error, 1)
|
|
versionCh := make(chan uint64, 1)
|
|
|
|
go func() {
|
|
defer close(dataCh)
|
|
defer close(errCh)
|
|
defer close(versionCh)
|
|
|
|
var buf bytes.Buffer
|
|
version, err := s.Backup(&buf, since, compress)
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
|
|
// Split into chunks
|
|
data := buf.Bytes()
|
|
for i := 0; i < len(data); i += s.chunkSize {
|
|
end := i + s.chunkSize
|
|
if end > len(data) {
|
|
end = len(data)
|
|
}
|
|
dataCh <- data[i:end]
|
|
}
|
|
|
|
versionCh <- version
|
|
}()
|
|
|
|
return dataCh, errCh, versionCh
|
|
}
|
|
|
|
// RestoreChunked restores from chunks.
|
|
// Send chunks to the returned channel, close when done.
|
|
func (s *Service) RestoreChunked(compressed bool) (chan<- []byte, <-chan error) {
|
|
dataCh := make(chan []byte, 10)
|
|
errCh := make(chan error, 1)
|
|
|
|
go func() {
|
|
defer close(errCh)
|
|
|
|
var buf bytes.Buffer
|
|
for chunk := range dataCh {
|
|
buf.Write(chunk)
|
|
}
|
|
|
|
if err := s.Restore(&buf, compressed); err != nil {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
|
|
return dataCh, errCh
|
|
}
|