hub v0.134.0: a down box is judged on longer missed-backup lines (R-872); household outage mail at most weekly (R-873); backup_catchup_done allowed (R-871)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 09:24:14 +02:00
parent 8e873ee4a0
commit 6da3a7d866
7 changed files with 278 additions and 1 deletions
+80 -1
View File
@@ -334,6 +334,68 @@ func stalenessState(staleness *StalenessChecker, customerID string) string {
return staleness.GetState(customerID)
}
// downDumpMissedAfter / downBackupMissedAfter (R-872): the lines a DOWN box is judged on. 48 h = two nights without
// a database dump (a box that died last night is the staleness alarm's alone). 72 h for the whole-guest backup = the
// agent's own catch-up valve (cadence 24 h + 24 h) plus a day. Decided by CC — operator may reverse.
const (
downDumpMissedAfter = 48 * time.Hour
downBackupMissedAfter = 72 * time.Hour
)
// judgeDownCustomer is R-872's judgement of a box that is down at the deadline. It raises at most one
// expected_dbdump_missed and one expected_backup_missed, each only past its longer line, and never for a box that
// was bound or first reported less than downDumpMissedAfter ago (nothing has been expected yet).
func judgeDownCustomer(s *store.Store, id string, now time.Time, onEvent EventNotifyFunc, logger *log.Logger) (backupMissed, dbdumpMissed int) {
if bound, err := s.HasEverBoundHost(id); err == nil && !bound {
return 0, 0
}
first, ferr := s.GetFirstHostReportAt(id)
if ferr != nil || first.IsZero() || now.Sub(first) < downDumpMissedAfter {
return 0, 0
}
raise := func(typ, msg string) {
if _, err := s.SaveEvent(id, typ, "error", msg, "{}", "hub"); err != nil {
logger.Printf("[WARN] Failed to save %s for %s: %v", typ, id, err)
} else if onEvent != nil {
onEvent(id, typ, "error", msg, "{}", "hub")
}
}
// Database dumps: the newest db_dump_completed in the last 7 days.
if dumps, err := s.GetEventsByType(id, "db_dump_completed", now.Add(-backupEvidenceLookback)); err == nil {
var newest time.Time
for _, e := range dumps {
if e.CreatedAt.After(newest) {
newest = e.CreatedAt
}
}
if newest.IsZero() || now.Sub(newest) > downDumpMissedAfter {
last := "none in the last 7 days"
if !newest.IsZero() {
last = newest.UTC().Format(time.RFC3339)
}
raise("expected_dbdump_missed", fmt.Sprintf("No DB dump for over %s while the box is down at its deadline (last: %s) — it may be switched off at its backup time (R-872)",
downDumpMissedAfter, last))
dbdumpMissed = 1
}
}
// Whole-guest backup: the newest backup any retained host report shows.
if rows, err := s.GetHostReportsSince(id, now.Add(-backupEvidenceLookback)); err == nil {
newest, have := newestBackupEvidence(rows, now)
if !have || now.Sub(newest) > downBackupMissedAfter {
last := "none in the last 7 days"
if have {
last = newest.UTC().Format(time.RFC3339)
}
raise("expected_backup_missed", fmt.Sprintf("No fresh verified backup for over %s while the box is down at its deadline (last: %s) (R-872)",
downBackupMissedAfter, last))
backupMissed = 1
}
}
logger.Printf("[INFO] Deadline check: %s is DOWN — judged on the longer lines (dump %s, whole-guest %s): dump missed=%d backup missed=%d",
id, downDumpMissedAfter, downBackupMissedAfter, dbdumpMissed, backupMissed)
return backupMissed, dbdumpMissed
}
func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent EventNotifyFunc, logger *log.Logger) {
customerIDs, err := s.GetActiveCustomerIDs()
if err != nil {
@@ -357,7 +419,24 @@ func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent E
// `expected_backup_missed` / `expected_dbdump_missed` every morning about a machine we asked
// to be quiet. That is R-195's shape exactly — a skip keyed off the wrong fact missing the
// customer it would most obviously cover — which is why both doors are closed together.
if st := stalenessState(staleness, id); st == "down" || st == StateDisabled {
st := stalenessState(staleness, id)
if st == StateDisabled {
skipped++
continue
}
// R-872 (v0.134.0): a box that is DOWN at 05:00 is no longer skipped outright. It was, so that a box that
// just died raises its staleness alarm and not a backup alarm too — but a box that is down at EVERY
// deadline (a laptop switched off at night, Tester 2, measured 2026-10-05: "1 skipped (down)") was then
// never judged at all, and its missing backups stayed silent for ever. A down box is now judged on a
// LONGER line (downDumpMissedAfter / downBackupMissedAfter): a box that died last night still raises only
// its staleness alarm; a box that has gone two nights without a dump raises the backup alarm, down or not.
// Pinned by TestR872_*.
if st == "down" {
if !s.IsCustomerBlocked(id) {
b, d := judgeDownCustomer(s, id, time.Now().UTC(), onEvent, logger)
backupMissed += b
dbdumpMissed += d
}
skipped++
continue
}