v0.15.2: Fix snapshot stats and DB validation loss on restart

This commit is contained in:
2026-02-19 08:45:37 +01:00
parent ea7105d468
commit d372454c18
2 changed files with 118 additions and 24 deletions
+9
View File
@@ -1,5 +1,14 @@
## Changelog ## Changelog
### What was just completed (2026-02-19 session 52)
- **v0.15.2 — Fix data loss on container restart (2 bugs):**
**Bug 1:** Snapshot history delta stats (HOZZÁADOTT, ÚJ FÁJL, VÁLTOZOTT) showed 0 after container restart because restic doesn't store these stats — they were only in memory. Fixed by persisting the snapshot history ring buffer to `data/snapshot-history.json`. On startup, persisted stats are merged with restic repo snapshots. Added `saveSnapshotHistory()` (atomic write via tmp+rename), `loadSnapshotHistoryFromFile()`, updated `appendSnapshotRecord()` to save after each backup, and updated `LoadSnapshotHistory()` to merge persisted + restic data.
**Bug 2:** DB validation (ÉRVÉNYESÍTÉS column) showed "" after restart because the synthesized `LastDBDump.Results` didn't copy `Validation` from `DumpFileInfo`. One-line fix: added `Validation: f.Validation` to the synthesized `DumpResult` in `GetFullStatus()`.
**Files modified:** `internal/backup/backup.go`
### What was just completed (2026-02-19 session 51) ### What was just completed (2026-02-19 session 51)
- **v0.15.1 — Backup Page "Részletek" Overhaul:** - **v0.15.1 — Backup Page "Részletek" Overhaul:**
+109 -24
View File
@@ -2,6 +2,7 @@ package backup
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -26,13 +27,14 @@ type Manager struct {
stackProvider StackDataProvider stackProvider StackDataProvider
systemDataPath string // fallback drive for SSD-only apps systemDataPath string // fallback drive for SSD-only apps
mu sync.Mutex mu sync.Mutex
lastDBDump *DBDumpStatus lastDBDump *DBDumpStatus
lastBackup *BackupStatus lastBackup *BackupStatus
running bool running bool
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
lastCheckTime time.Time snapshotHistoryFile string // path to persist snapshot history JSON
lastCheckOK bool lastCheckTime time.Time
lastCheckOK bool
// Cached status for page rendering (refreshed periodically) // Cached status for page rendering (refreshed periodically)
cachedStatus *FullBackupStatus cachedStatus *FullBackupStatus
@@ -145,13 +147,18 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti
if cfg.Paths.SystemDataPath == "" { if cfg.Paths.SystemDataPath == "" {
logger.Printf("[WARN] SystemDataPath is empty in config — SSD-only apps will not have correct backup paths") logger.Printf("[WARN] SystemDataPath is empty in config — SSD-only apps will not have correct backup paths")
} }
dataDir := cfg.Paths.DataDir
if dataDir == "" {
dataDir = "/opt/docker/felhom-controller/data"
}
return &Manager{ return &Manager{
cfg: cfg, cfg: cfg,
restic: NewResticManager(cfg, logger), restic: NewResticManager(cfg, logger),
logger: logger, logger: logger,
pinger: pinger, pinger: pinger,
settings: sett, settings: sett,
systemDataPath: cfg.Paths.SystemDataPath, systemDataPath: cfg.Paths.SystemDataPath,
snapshotHistoryFile: filepath.Join(dataDir, "snapshot-history.json"),
} }
} }
@@ -793,10 +800,65 @@ func (m *Manager) appendSnapshotRecord(rec SnapshotRecord) {
if len(m.snapshotHistory) > 20 { if len(m.snapshotHistory) > 20 {
m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:]
} }
m.saveSnapshotHistory()
} }
// LoadSnapshotHistory populates the snapshot history from all primary restic repos on startup. // saveSnapshotHistory persists the current snapshotHistory to disk as JSON.
// Caller must hold m.mu. Writes atomically (tmp file + rename).
func (m *Manager) saveSnapshotHistory() {
if m.snapshotHistoryFile == "" {
return
}
data, err := json.Marshal(m.snapshotHistory)
if err != nil {
m.logger.Printf("[WARN] Could not marshal snapshot history: %v", err)
return
}
tmp := m.snapshotHistoryFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
m.logger.Printf("[WARN] Could not write snapshot history tmp file: %v", err)
return
}
if err := os.Rename(tmp, m.snapshotHistoryFile); err != nil {
m.logger.Printf("[WARN] Could not rename snapshot history file: %v", err)
}
}
// loadSnapshotHistoryFromFile reads the persisted snapshot history from disk.
// Returns nil if the file does not exist or cannot be read.
func (m *Manager) loadSnapshotHistoryFromFile() []SnapshotRecord {
if m.snapshotHistoryFile == "" {
return nil
}
data, err := os.ReadFile(m.snapshotHistoryFile)
if err != nil {
if !os.IsNotExist(err) {
m.logger.Printf("[WARN] Could not read snapshot history file: %v", err)
}
return nil
}
var records []SnapshotRecord
if err := json.Unmarshal(data, &records); err != nil {
m.logger.Printf("[WARN] Could not parse snapshot history file: %v", err)
return nil
}
return records
}
// LoadSnapshotHistory populates the snapshot history on startup.
// First tries to load persisted history (with delta stats) from disk.
// Merges with restic repo snapshots to pick up any entries not in the persisted file.
func (m *Manager) LoadSnapshotHistory() { func (m *Manager) LoadSnapshotHistory() {
// Try loading persisted records (contains delta stats from actual backup runs)
persisted := m.loadSnapshotHistoryFromFile()
// Build a lookup map of persisted records by SnapshotID
persistedByID := make(map[string]SnapshotRecord, len(persisted))
for _, r := range persisted {
persistedByID[r.SnapshotID] = r
}
// Query restic repos for any snapshots not in the persisted file
drives := m.activeDrives() drives := m.activeDrives()
var allSnapshots []SnapshotInfo var allSnapshots []SnapshotInfo
@@ -821,18 +883,40 @@ func (m *Manager) LoadSnapshotHistory() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
for _, s := range allSnapshots { if len(persisted) > 0 {
m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{ // Start from persisted records, add any restic snapshots not already there
SnapshotID: s.ID, m.snapshotHistory = persisted
Time: s.Time, for _, s := range allSnapshots {
HasStats: false, // historical — no delta stats available if _, found := persistedByID[s.ID]; !found {
Success: true, m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{
SnapshotID: s.ID,
Time: s.Time,
HasStats: false,
Success: true,
})
}
}
// Re-sort by time after merge (oldest first for ring buffer)
sort.Slice(m.snapshotHistory, func(i, j int) bool {
return m.snapshotHistory[i].Time.Before(m.snapshotHistory[j].Time)
}) })
m.logger.Printf("[INFO] Loaded %d snapshots from persisted history (merged with %d restic entries)", len(persisted), len(allSnapshots))
} else {
// No persisted file — fall back to restic-only loading (first run)
for _, s := range allSnapshots {
m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{
SnapshotID: s.ID,
Time: s.Time,
HasStats: false,
Success: true,
})
}
m.logger.Printf("[INFO] Loaded %d historical snapshots from %d restic repos (no persisted history)", len(m.snapshotHistory), len(drives))
} }
if len(m.snapshotHistory) > 20 { if len(m.snapshotHistory) > 20 {
m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:]
} }
m.logger.Printf("[INFO] Loaded %d historical snapshots from %d repos", len(m.snapshotHistory), len(drives))
} }
// RefreshCache updates the cached full status. Called by scheduler every 5 minutes // RefreshCache updates the cached full status. Called by scheduler every 5 minutes
@@ -976,9 +1060,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
var latestTime time.Time var latestTime time.Time
for _, f := range status.DumpFiles { for _, f := range status.DumpFiles {
results = append(results, DumpResult{ results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName}, DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName, FilePath: f.FileName,
Size: f.Size, Size: f.Size,
Validation: f.Validation,
}) })
if f.ModTime.After(latestTime) { if f.ModTime.After(latestTime) {
latestTime = f.ModTime latestTime = f.ModTime