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

This commit is contained in:
2026-10-25 09:33:26 +01:00
parent 02daebe9c5
commit b680db85cb
18 changed files with 405 additions and 23 deletions
+19 -5
View File
@@ -1900,7 +1900,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.unit_failed", s.noteErr(err)))
s.endRestoreFailed("note.restore.unit_failed", err, "")
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
@@ -1916,11 +1916,25 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
}
// restoreFailedNote is the household's sentence for a restore that returned an error: key's „…sikertelen: %s"
// around the error's own sentence.
// around the error's own sentence — except a restore whose data is back but whose app did not start (R-1270), which is
// neither a success nor a failure and reads its own sentence alone.
func (s *Server) restoreFailedNote(key string, err error) string {
if errors.Is(err, backup.ErrRestoreNotStarted) {
return s.noteErr(err)
}
return s.note(key, s.noteErr(err))
}
// endRestoreFailed records a restore that returned an error: a not-started one (R-1270) as such — never ok, and a late
// start can still turn it into a success — everything else as a failure.
func (s *Server) endRestoreFailed(key string, err error, suffix string) {
if errors.Is(err, backup.ErrRestoreNotStarted) {
s.backupMgr.EndRestoreOpNotStarted(s.noteErr(err) + suffix)
return
}
s.backupMgr.EndRestoreOp(false, s.note(key, s.noteErr(err))+suffix)
}
// unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure,
// so the wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised.
@@ -2232,7 +2246,7 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
return
}
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.file_failed", s.noteErr(err)))
s.endRestoreFailed("note.restore.file_failed", err, "")
return
}
// C9-F1 (the quiet half): even where the restore DOES cover something it covers only the
@@ -2302,7 +2316,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
files := s.note("note.restore.whole_files", res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 whole restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err))+" "+files)
s.endRestoreFailed("note.restore.full_unit_failed", err, " "+files)
return
}
s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)",
@@ -2336,7 +2350,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
res, err := s.backupMgr.RestoreTier2Unit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err)))
s.endRestoreFailed("note.restore.full_unit_failed", err, "")
return
}
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
+1 -1
View File
@@ -111,7 +111,7 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request)
app, res, err := s.backupMgr.RestoreSetAsideUnit(id)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore from the set-aside unit failed (async): stack=%s: %v", app, err)
s.backupMgr.EndRestoreOp(false, s.restoreFailedNote("note.restore.unit_failed", err))
s.endRestoreFailed("note.restore.unit_failed", err, "")
return
}
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(app, res))
+1 -1
View File
@@ -545,7 +545,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app, ackPlacement)
if err != nil {
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_failed", s.noteErr(err)))
s.endRestoreFailed("note.restore.full_failed", err, "")
return
}
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
@@ -0,0 +1,40 @@
package web
import (
"errors"
"io"
"log"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-1270 — a restore whose data is back but whose app did not start is recorded as NOT ok, with its own sentence
// alone (never „Visszaállítás sikertelen: …", never „…az alkalmazás újraindult."); any other error stays a failure.
// COMPANION RED-PROOF (REPORT.md): drop the errors.Is branch in endRestoreFailed → this fails on the „sikertelen" prefix
// and on NotStarted.
func TestR1270_TheRecordOfANotStartedRestore(t *testing.T) {
lg := log.New(io.Discard, "", 0)
cfg := &config.Config{}
cfg.Paths.DataDir = t.TempDir()
s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, nil, lg), logger: lg}
s.backupMgr.BeginRestoreOp("restore", "tandoor")
s.endRestoreFailed("note.restore.unit_failed", util.MsgErrorf(backup.ErrRestoreNotStarted, "err.backup.restore_not_started", "tandoor"), "")
st := s.backupMgr.RestoreStatus()
if st.Last == nil || st.Last.OK || !st.Last.NotStarted {
t.Fatalf("a not-started restore is recorded not ok and not started: %+v", st.Last)
}
if strings.Contains(st.Last.Message, "sikertelen") || strings.Contains(st.Last.Message, "újraindult") || !strings.Contains(st.Last.Message, "még nem indult el") {
t.Fatalf("the household reads the not-started sentence alone: %q", st.Last.Message)
}
s.backupMgr.BeginRestoreOp("restore", "tandoor")
s.endRestoreFailed("note.restore.unit_failed", errors.New("boom"), "")
if st := s.backupMgr.RestoreStatus(); st.Last.OK || st.Last.NotStarted || !strings.Contains(st.Last.Message, "sikertelen") {
t.Fatalf("any other error stays a failure: %+v", st.Last)
}
}