ff69074514
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
114 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|