D22: every restore puts the data back exactly on failure and after a power cut (R-1177, R-1195); whole restore asks the unit first (R-1178); no mirror during a restore (R-1180); W22 restore refusals in Hungarian; unreleased
gates / gates (push) Successful in 1m40s

This commit is contained in:
2026-10-21 21:19:46 +02:00
parent 7848d4aaac
commit d582feb245
25 changed files with 1065 additions and 71 deletions
@@ -46,6 +46,8 @@ const (
ReasonOffboxReconstitute AppStopReason = "offbox_reconstitute"
// ReasonAppExport — a .fab export taken with "stop the app first".
ReasonAppExport AppStopReason = "app_export"
// ReasonUnitRestore — a local restore from a recovery unit (the app's own, the second drive's, a kept one).
ReasonUnitRestore AppStopReason = "unit_restore"
)
// humanReason is the operator-facing phrasing for each reason.
@@ -57,6 +59,8 @@ func (r AppStopReason) humanReason() string {
return "an off-site restore"
case ReasonAppExport:
return "an app export"
case ReasonUnitRestore:
return "a local restore"
default:
return string(r)
}
@@ -74,6 +78,8 @@ func (r AppStopReason) MessageKey() string {
return "appstop.reason.offbox_reconstitute"
case ReasonAppExport:
return "appstop.reason.app_export"
case ReasonUnitRestore:
return "appstop.reason.unit_restore"
default:
return ""
}
@@ -87,6 +93,9 @@ type AppStopMarker struct {
Reason AppStopReason `json:"reason"`
Stacks []string `json:"stacks"`
StartedAt time.Time `json:"started_at"`
// Undo (D22, R-1195) names, per app, the copy of its live volumes and definition a restore took right after the
// stop (restore_crash_putback.go). Set only once the copy is whole; Recover pours it back before the restart.
Undo map[string]string `json:"undo,omitempty"`
}
// AppStopStarter is the one thing recovery needs: the ability to start a stack. StartStack must be
@@ -121,6 +130,9 @@ type AppStopGuard struct {
now func() time.Time
// starter is only needed by Recover; Begin/End work without one.
starter AppStopStarter
// putBacker (D22, R-1195) pours a restore's undo copy back before Recover restarts the app. nil = no put-back
// (the pre-D22 restart, unchanged).
putBacker InterruptedRestorePutBacker
// suppressed (R-330) is the in-memory "the alarm must not fire for these, we are holding them"
// set. It is deliberately NOT persisted: after a restart the guard no longer holds anything —
@@ -147,6 +159,9 @@ type AppStopRecovery struct {
// (R-174). The marker is kept for these too, but they are NOT a fault and MUST NOT alarm: the
// holder owns the restart. Separate from Failed for the reason recorded on ErrStartRefused.
Refused []string
// PutBack are apps whose interrupted restore was undone from its copy before the restart (D22, R-1195): their
// data is as it was before the restore.
PutBack []string
}
// Alarming reports whether this recovery is worth paging an operator about. A recovery that only
@@ -197,6 +212,9 @@ func (r *AppStopRecovery) Detail() string {
if len(r.Refused) > 0 {
d += fmt.Sprintf(" held_by_drive=%v", r.Refused)
}
if len(r.PutBack) > 0 {
d += fmt.Sprintf(" put_back=%v", r.PutBack)
}
return d
}
@@ -218,6 +236,43 @@ func (g *AppStopGuard) SetStarter(s AppStopStarter) {
g.starter = s
}
// InterruptedRestorePutBacker pours an interrupted restore's undo copy back (restore_crash_putback.go). It must leave
// the app stopped; Recover starts it.
type InterruptedRestorePutBacker interface {
PutBackInterruptedRestore(stack, undoDir string) error
}
// SetPutBacker wires the put-back Recover runs for an app whose restore was cut after its copy was taken.
func (g *AppStopGuard) SetPutBacker(p InterruptedRestorePutBacker) {
if g != nil {
g.putBacker = p
}
}
// SetUndo records, in the active marker, where stack's undo copy is (D22, R-1195). Called once the copy is whole;
// a marker that is not active (or not for this app) is left alone.
func (g *AppStopGuard) SetUndo(stack, dir string) error {
if g == nil || g.path == "" {
return nil
}
m, ok := g.read()
if !ok || !m.Active {
return fmt.Errorf("no active app-stop marker to record the undo copy in")
}
found := false
for _, s := range m.Stacks {
found = found || s == stack
}
if !found {
return fmt.Errorf("the active app-stop marker does not hold %s", stack)
}
if m.Undo == nil {
m.Undo = map[string]string{}
}
m.Undo[stack] = dir
return g.write(m)
}
// Begin records that `stacks` are about to be stopped by `reason`. It MUST be called BEFORE the
// first stop — an error here means the marker could not be written, and the caller must not proceed
// to stop an app it cannot promise to restart.
@@ -304,6 +359,21 @@ func (g *AppStopGuard) Recover() *AppStopRecovery {
g.logger.Printf("[INFO] [appstop] crash recovery: NOT restarting %s — the household stopped it", name)
continue
}
if dir := m.Undo[name]; dir != "" && g.putBacker != nil {
// D22 (R-1195): the restore was cut after its copy of the live data was taken — the app may be on the
// backup's data, or on a mix. Pour the copy back first; a put-back that fails is a FAILED recovery
// (the marker is kept, so the next startup tries again) and the app is not started on unknown data.
if _, statErr := os.Stat(dir); statErr != nil {
g.logger.Printf("[WARN] [appstop] crash recovery: %s's restore copy %s is gone (%v) — restarting as before", name, dir, statErr)
} else if err := g.putBacker.PutBackInterruptedRestore(name, dir); err != nil {
g.logger.Printf("[ERROR] [appstop] crash recovery: putting %s back from %s FAILED: %v — NOT starting it", name, dir, err)
res.Failed = append(res.Failed, name)
continue
} else {
g.logger.Printf("[WARN] [appstop] crash recovery: %s's interrupted restore is undone — its data is as before the restore (R-1195)", name)
res.PutBack = append(res.PutBack, name)
}
}
if err := g.starter.StartStack(name); err != nil {
// R-174: a REFUSAL is not a failure. The starter's gate has said this app must not be
// started (an absent data drive), so the app is left down deliberately and the holder
@@ -324,6 +394,7 @@ func (g *AppStopGuard) Recover() *AppStopRecovery {
sort.Strings(res.Failed)
sort.Strings(res.Refused)
sort.Strings(res.Restarted)
sort.Strings(res.PutBack)
// The marker is kept for BOTH unfinished outcomes, for the same reason and with different
// urgency: a failed restart is retried next startup, and a refused one is genuinely unfinished