R-1270 (P2): a restore whose app does not reach running in time is never a success — every restore path returns ErrRestoreNotStarted, the household reads that the data is back but the app has not started yet, one operator alarm; the data stays, the undo copy is kept, a late start turns the record into a success and releases the copy. Red-proved. Commit only.
gates / gates (push) Successful in 1m32s
gates / gates (push) Successful in 1m32s
This commit is contained in:
1 parent
02daebe9c5
commit
b680db85cb
18 files changed
+405
-23
No files matched your search
@@ -0,0 +1,150 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-1270 (brief 2026-10-25 Part 3) — the verdict follows the app. Measured before (9202, 0.342.0 = `e1613ae` for these
|
||||
// files, `audits/restore-truth-2026-10-25/before/`): the Tandoor restore whose app never came up was recorded `ok: true`,
|
||||
// „…visszaállítva — az alkalmazás újraindult."
|
||||
// COMPANION RED-PROOF (REPORT.md): with the health failure back to a WARN (restoreNotStarted not called),
|
||||
// TestR1270_AUnitRestoreWhoseAppDoesNotStartIsNotASuccess and TestR1270_TheOffsiteRestoreToo fail — the restore
|
||||
// returns nil and the copy is removed.
|
||||
|
||||
// r1270Provider is the D22 rig's provider whose app reaches running only when told to (the late start).
|
||||
type r1270Provider struct {
|
||||
*r1177Provider
|
||||
up atomic.Bool
|
||||
}
|
||||
|
||||
func (p *r1270Provider) RefreshAndIsRunning(string) bool { return p.up.Load() }
|
||||
|
||||
func r1270Rig(t *testing.T) (*Manager, *r1270Provider, *r1177Rig, *[]string) {
|
||||
t.Helper()
|
||||
m, p, rig, alarms := r1177Fixture(t)
|
||||
np := &r1270Provider{r1177Provider: p}
|
||||
m.stackProvider = np
|
||||
var got []string
|
||||
m.SetRestoreNotStartedNotify(func(stack, kind string, cause error) { got = append(got, stack+" "+kind) })
|
||||
_ = alarms
|
||||
return m, np, rig, &got
|
||||
}
|
||||
|
||||
// Health fails → not ok, the household's own sentence, the undo copy STAYS, one operator alarm.
|
||||
func TestR1270_AUnitRestoreWhoseAppDoesNotStartIsNotASuccess(t *testing.T) {
|
||||
lateStartWatch = 0 // no watcher in this test
|
||||
defer func() { lateStartWatch = 30 * time.Minute }()
|
||||
m, _, rig, alarms := r1270Rig(t)
|
||||
_, err := m.RestoreFromRecoveryUnit("app")
|
||||
if !errors.Is(err, ErrRestoreNotStarted) {
|
||||
t.Fatalf("a restore whose app did not start must not be a success, got %v", err)
|
||||
}
|
||||
if want := util.MsgError("err.backup.restore_not_started", "app").Error(); err.Error() != want {
|
||||
t.Fatalf("the household reads the not-started sentence, got %q", err.Error())
|
||||
}
|
||||
if left := undoCopies(t, rig.undoRoot); len(left) != 1 {
|
||||
t.Fatalf("the restore's copy must be KEPT (not released) while the app has not started, found %v", left)
|
||||
}
|
||||
if len(*alarms) != 1 || !strings.Contains((*alarms)[0], "unit restore") {
|
||||
t.Fatalf("the operator gets exactly one alarm, got %v", *alarms)
|
||||
}
|
||||
if len(rig.poured) != 0 {
|
||||
t.Fatalf("a health timeout must NOT pour the old data back (a slow first start is common): %v", rig.poured)
|
||||
}
|
||||
}
|
||||
|
||||
// Health passes → as before: success, the copy released.
|
||||
func TestR1270_AHealthyRestoreIsASuccessAsBefore(t *testing.T) {
|
||||
m, p, rig, alarms := r1270Rig(t)
|
||||
p.up.Store(true)
|
||||
if _, err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("a healthy restore is a success: %v", err)
|
||||
}
|
||||
if left := undoCopies(t, rig.undoRoot); len(left) != 0 {
|
||||
t.Fatalf("the copy is released after a healthy restore, found %v", left)
|
||||
}
|
||||
if len(*alarms) != 0 {
|
||||
t.Fatalf("no alarm for a healthy restore: %v", *alarms)
|
||||
}
|
||||
}
|
||||
|
||||
// A late start turns the record into a success and releases the kept copy (the normal rule).
|
||||
func TestR1270_ALateStartTurnsTheRecordIntoASuccess(t *testing.T) {
|
||||
lateStartWatch, lateStartPoll = 2*time.Second, 5*time.Millisecond
|
||||
defer func() { lateStartWatch, lateStartPoll = 30*time.Minute, 30*time.Second }()
|
||||
m, p, rig, _ := r1270Rig(t)
|
||||
m.BeginRestoreOp("restore", "app")
|
||||
_, err := m.RestoreFromRecoveryUnit("app")
|
||||
if !errors.Is(err, ErrRestoreNotStarted) {
|
||||
t.Fatalf("precondition: not started, got %v", err)
|
||||
}
|
||||
m.EndRestoreOpNotStarted(err.Error())
|
||||
if st := m.RestoreStatus(); st.Last == nil || st.Last.OK || !st.Last.NotStarted {
|
||||
t.Fatalf("the record must say not ok / not started: %+v", st.Last)
|
||||
}
|
||||
p.up.Store(true)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if st := m.RestoreStatus(); st.Last != nil && st.Last.OK {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
st := m.RestoreStatus()
|
||||
if st.Last == nil || !st.Last.OK || st.Last.NotStarted {
|
||||
t.Fatalf("a late start must turn the record into a success: %+v", st.Last)
|
||||
}
|
||||
for i := 0; i < 100 && len(undoCopies(t, rig.undoRoot)) != 0; i++ {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if left := undoCopies(t, rig.undoRoot); len(left) != 0 {
|
||||
t.Fatalf("the kept copy is released once the app runs, found %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// The off-site restore: the same verdict, its undo copy kept.
|
||||
func TestR1270_TheOffsiteRestoreToo(t *testing.T) {
|
||||
lateStartWatch = 0
|
||||
defer func() { lateStartWatch = 30 * time.Minute }()
|
||||
m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
m.stackProvider = ¬RunningRecordingProvider{prov}
|
||||
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
|
||||
if !errors.Is(err, ErrRestoreNotStarted) {
|
||||
t.Fatalf("an off-site restore whose app did not start must not be a success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type notRunningRecordingProvider struct{ *recordingProvider }
|
||||
|
||||
func (p *notRunningRecordingProvider) RefreshAndIsRunning(string) bool { return false }
|
||||
|
||||
// The record a late start may change is only that app's not-started record, and never while a restore runs.
|
||||
func TestR1270_MarkLateStartTouchesOnlyItsOwnRecord(t *testing.T) {
|
||||
m := &Manager{}
|
||||
m.BeginRestoreOp("restore", "a")
|
||||
m.EndRestoreOp(false, "a failed")
|
||||
m.markLateStart("a", "started")
|
||||
if st := m.RestoreStatus(); st.Last.OK {
|
||||
t.Fatal("a plain failure must not become a success")
|
||||
}
|
||||
m.BeginRestoreOp("restore", "a")
|
||||
m.EndRestoreOpNotStarted("not yet")
|
||||
m.markLateStart("b", "started")
|
||||
if st := m.RestoreStatus(); st.Last.OK {
|
||||
t.Fatal("another app's start must not change this record")
|
||||
}
|
||||
m.BeginRestoreOp("restore", "a")
|
||||
m.markLateStart("a", "started")
|
||||
if st := m.RestoreStatus(); !st.Running {
|
||||
t.Fatal("a running restore must stay running")
|
||||
}
|
||||
_ = os.Getpid
|
||||
}
|
||||
Reference in new issue
Block a user