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) } }