Files
felhom-controller/controller/internal/backup/run_record.go
T

114 lines
3.9 KiB
Go

package backup
import (
"encoding/json"
"os"
"time"
)
// R-519 (controller v0.296.0) — an app-data backup run cut off by a power cut or a restart is SAID on the page.
//
// MEASURED 2026-09-14 (BIGNIGHT F2): the power was cut during a volume dump; afterwards /backups and /backups/apps
// said nothing of an interruption, and every app read „Utolsó: 5 perce". The controller knew only when apps had
// been stopped (the app-stop marker); a cut during the DATABASE leg, with every app running, left no trace at all.
//
// Shape (the restore record's, R-550): one JSON file in DataDir, written atomically at BOTH ends of a run. At
// startup a file still marked running is, by construction, a run nothing is running any more: it becomes the
// INTERRUPTED notice, kept until the next app-data run that ends with every step OK. The restore points of a torn
// run already carry the time of their OLDEST part (the data block, v0.275.0) — this adds the sentence.
// No path set (a bare Manager in a test, a box with backup disabled) = no persistence, silently.
//
// Pinned by run_record_test.go (TestRunRecord_*).
type runRecordFile struct {
Running bool `json:"running"`
StartedAt time.Time `json:"started_at,omitempty"`
Interrupted time.Time `json:"interrupted,omitempty"` // the start of the run that was cut off; zero = none
}
// SetRunRecordPath wires persistence. Call before LoadRunRecord and before any backup runs.
func (m *Manager) SetRunRecordPath(path string) {
m.mu.Lock()
defer m.mu.Unlock()
m.runRecordPath = path
}
// LoadRunRecord reads the record at startup. It returns the start time of a run the stop cut off — non-zero
// exactly once per interruption (the conversion is written back), so the caller logs it once.
func (m *Manager) LoadRunRecord() time.Time {
m.mu.Lock()
defer m.mu.Unlock()
if m.runRecordPath == "" {
return time.Time{}
}
rec, ok := m.readRunRecordLocked()
if !ok {
return time.Time{}
}
m.runInterrupted = rec.Interrupted
if !rec.Running {
return time.Time{}
}
rec.Running = false
rec.Interrupted = rec.StartedAt
m.runInterrupted = rec.StartedAt
m.writeRunRecordLocked(rec)
return rec.StartedAt
}
// InterruptedRunAt is the start of the last app-data run that was cut off, or zero when the newest run finished.
func (m *Manager) InterruptedRunAt() time.Time {
m.mu.Lock()
defer m.mu.Unlock()
return m.runInterrupted
}
// markRunStarted is called at the start of an app-data run (runDBDumpsInternal).
func (m *Manager) markRunStarted(at time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
if m.runRecordPath == "" {
return
}
m.writeRunRecordLocked(runRecordFile{Running: true, StartedAt: at, Interrupted: m.runInterrupted})
}
// markRunEnded is called on EVERY return of the run. allOK clears the interrupted notice: the household's copies
// are whole again. A run that ended with a failed step keeps it (that run has its own failure line too).
func (m *Manager) markRunEnded(allOK bool) {
m.mu.Lock()
defer m.mu.Unlock()
if allOK {
m.runInterrupted = time.Time{}
}
if m.runRecordPath == "" {
return
}
m.writeRunRecordLocked(runRecordFile{Running: false, Interrupted: m.runInterrupted})
}
func (m *Manager) readRunRecordLocked() (runRecordFile, bool) {
var rec runRecordFile
b, err := os.ReadFile(m.runRecordPath)
if err != nil {
if !os.IsNotExist(err) && m.logger != nil {
m.logger.Printf("[WARN] [backup] run record unreadable (%v) — no interrupted-run notice", err)
}
return rec, false
}
if err := json.Unmarshal(b, &rec); err != nil {
if m.logger != nil {
m.logger.Printf("[WARN] [backup] run record is not JSON (%v) — no interrupted-run notice", err)
}
return rec, false
}
return rec, true
}
func (m *Manager) writeRunRecordLocked(rec runRecordFile) {
b, _ := json.Marshal(rec)
if err := atomicWrite(m.runRecordPath, b, 0o600); err != nil && m.logger != nil {
m.logger.Printf("[WARN] [backup] could not persist the run record to %s: %v", m.runRecordPath, err)
}
}