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
### 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)
- **v0.15.1 — Backup Page "Részletek" Overhaul:**
+109 -24
View File
@@ -2,6 +2,7 @@ package backup
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -26,13 +27,14 @@ type Manager struct {
stackProvider StackDataProvider
systemDataPath string // fallback drive for SSD-only apps
mu sync.Mutex
lastDBDump *DBDumpStatus
lastBackup *BackupStatus
running bool
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
lastCheckTime time.Time
lastCheckOK bool
mu sync.Mutex
lastDBDump *DBDumpStatus
lastBackup *BackupStatus
running bool
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
snapshotHistoryFile string // path to persist snapshot history JSON
lastCheckTime time.Time
lastCheckOK bool
// Cached status for page rendering (refreshed periodically)
cachedStatus *FullBackupStatus
@@ -145,13 +147,18 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti
if cfg.Paths.SystemDataPath == "" {
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{
cfg: cfg,
restic: NewResticManager(cfg, logger),
logger: logger,
pinger: pinger,
settings: sett,
systemDataPath: cfg.Paths.SystemDataPath,
cfg: cfg,
restic: NewResticManager(cfg, logger),
logger: logger,
pinger: pinger,
settings: sett,
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 {
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() {
// 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()
var allSnapshots []SnapshotInfo
@@ -821,18 +883,40 @@ func (m *Manager) LoadSnapshotHistory() {
m.mu.Lock()
defer m.mu.Unlock()
for _, s := range allSnapshots {
m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{
SnapshotID: s.ID,
Time: s.Time,
HasStats: false, // historical — no delta stats available
Success: true,
if len(persisted) > 0 {
// Start from persisted records, add any restic snapshots not already there
m.snapshotHistory = persisted
for _, s := range allSnapshots {
if _, found := persistedByID[s.ID]; !found {
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 {
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
@@ -976,9 +1060,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
var latestTime time.Time
for _, f := range status.DumpFiles {
results = append(results, DumpResult{
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName,
Size: f.Size,
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
FilePath: f.FileName,
Size: f.Size,
Validation: f.Validation,
})
if f.ModTime.After(latestTime) {
latestTime = f.ModTime