Files
felhom-controller/controller/internal/backup/runsummary.go
T
admin 88897a224e
gates / gates (push) Successful in 8s
v0.194.0 — one operator email per backup run, and nothing dropped without a trace (R-182)
MEASURED, not supposed. On 2026-08-03 nine per-app recovery_unit_capture_failed
events reached the hub and TWO operator emails went out. The hub's operator
cooldown key is customerID:eventType(+tier) and that event carries `app` but no
`tier`, so the key held no app identifier: the first refused app took the hour's
slot and every other app's failure was discarded BEFORE anything was written
down, leaving no row on any channel.

The obvious fix — put `app` in the key — was ruled against: on a full disk it
produces one email per app, the volume problem wearing the correctness problem's
clothes.

internal/backup/runsummary.go: a per-run collector with exactly admissionSet's
lifetime, fed by all three write legs, emitting backup_run_failures ONCE at the
end and only when something failed. A clean run emits nothing.

The per-app event stays and becomes the RECORD — the hub routes it record-only,
stored and logged every time, never competing for an email slot. The record and
the notification are now different things.

Deliberate skips (disconnected, decommissioned) are excluded: they have their
own alert, and a nightly email about an unplugged drive is one the operator
learns to ignore.

A manual run always reports: the digest carries a unique run_id the cooldown
cannot collapse. Someone pressing the button is actively trying to get a backup.

THE PERIODIC SWEEP GETS A DIGEST TOO. With the per-app event now record-only, a
capture failure found between runs would be recorded and never notified — a new
silence introduced while closing one. That path emits a digest with NO run_id,
so the ordinary 1-hour cooldown caps it exactly as before while the mail now
lists every failing app instead of whichever was first.

A refusal is recorded ONCE, where the verdict is taken, not at the three legs
that consult it — R-181's contract is one verdict per app per run. Noting it per
leg listed one refused app three times and produced "2 of 1 apps failed". Found
by the digest's own test, not in review.

Silence is safe because the hub's deadline check raises expected_backup_missed
from report freshness, independently of any mail this box sends
(monitor/deadline.go:396,417). Confirmed, not assumed.

7 new tests, 4 red-proofs. The main.go seam walk did NOT fail on its first
attempt — the AST test walked the backup package and not main.go; the test was
fixed and the mutation re-run rather than the pass recorded.
2026-08-03 13:46:14 +02:00

271 lines
10 KiB
Go

package backup
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ── The backup run digest (R-182) ────────────────────────────────────────────────────────────────
//
// WHAT WAS WRONG, MEASURED. On 2026-08-03 nine per-app `recovery_unit_capture_failed` events reached
// the hub and TWO operator e-mails went out. The hub's operator cooldown key is
// `customerID + ":" + eventType + tier-suffix`, and that event carries `app` but no `tier`, so the
// key held **no app identifier**: the first refused app took the hour's slot and every other app's
// failure was discarded — *before* anything was written down, so it left no row on any channel and
// could not be found afterwards.
//
// WHY NOT JUST PUT THE APP IN THE KEY. That was the obvious fix and the operator ruled against it:
// on a full disk it produces one e-mail per app, which is the volume problem wearing the correctness
// problem's clothes. **One digest per run instead**, listing every failure — and separately, every
// failure recorded when it happens.
//
// THE RECORD AND THE NOTIFICATION ARE DIFFERENT THINGS, and that separation is the durable part:
//
// - the RECORD is the per-app `recovery_unit_capture_failed` event, emitted unconditionally, now
// routed record-only by the hub so it never competes for an e-mail slot;
// - the NOTIFICATION is this digest, emitted once per run and only when something failed.
//
// WHY A DIGEST IS SAFE HERE — the one thing that could have made it dangerous. A digest introduces a
// silent-failure path if the ABSENCE of an e-mail could mean "the run never finished". It cannot:
// the hub's daily deadline check raises `expected_backup_missed` / `expected_dbdump_missed`
// (`hub/internal/monitor/deadline.go:396,417`) from the box's REPORT freshness and its stored
// events, entirely independently of any mail the controller chooses to send. Silence therefore still
// means "the run finished and found nothing wrong". If that check is ever weakened, this design
// loses its footing — which is why it is named here and not only in a report.
// runKind distinguishes the paths that can produce a digest. It is carried in the details and shown
// in the subject, because "the nightly run failed" and "the run I just triggered failed" are read
// differently at 07:00.
const (
runKindNightly = "nightly"
runKindManual = "manual"
// runKindRefresh is the PERIODIC status sweep, which captures units outside any backup run.
//
// IT DELIBERATELY CARRIES NO run_id. A run digest gets a unique run id so the hub's 1-hour
// cooldown can never collapse two real runs (the operator ruled on that explicitly). The sweep
// is the opposite case: it can fire every time the status page is polled, so it MUST fall under
// the ordinary cooldown or a full disk becomes a mail flood — the exact failure this whole
// change exists to avoid, arriving from the other direction.
//
// Without this path the sweep's failures would be recorded and never notified, because the
// per-app event is now record-only — a NEW silence introduced while fixing a silence. This is
// what stops that.
runKindRefresh = "refresh"
)
// runFailure is one app's failed or refused leg within a run.
type runFailure struct {
App string
Leg string
Reason string
}
// runSummary is the per-RUN collector. Same lifetime as `admissionSet` and for the same reason: an
// absent collector must mean "no run in flight", never "a stale answer from last night".
type runSummary struct {
mu sync.Mutex
kind string
runID string
attempted map[string]bool // apps this run actually tried to back up
failures []runFailure
}
// beginRunSummary opens the per-run digest scope and returns the closer, mirroring
// beginAdmissionRun. A second call while one is live REPLACES it and the closer restores the
// previous, so nesting cannot silently drop a caller's scope.
func (m *Manager) beginRunSummary(kind, runID string) func() {
m.summaryMu.Lock()
prev := m.summary
m.summary = &runSummary{kind: kind, runID: runID, attempted: map[string]bool{}}
m.summaryMu.Unlock()
return func() {
m.summaryMu.Lock()
m.summary = prev
m.summaryMu.Unlock()
}
}
// noteAttempted records that this run tried to back up an app.
//
// THE DENOMINATOR IS NOT DECORATION. "3 of 4 apps failed" is a catastrophe and "3 of 40" is a bad
// night, and a list of names cannot tell them apart — the operator's first decision, get up now or
// look after coffee, is made from exactly this ratio.
func (m *Manager) noteAttempted(app string) {
m.summaryMu.Lock()
defer m.summaryMu.Unlock()
if m.summary != nil {
m.summary.mu.Lock()
m.summary.attempted[app] = true
m.summary.mu.Unlock()
}
}
// noteFailure records one failed or refused leg. Deliberate skips must NOT come through here —
// §8.1: a drive that is unplugged or decommissioned has its own alert, and putting it in the digest
// turns a nightly e-mail into one the operator learns to ignore.
func (m *Manager) noteFailure(app, leg, reason string) {
m.summaryMu.Lock()
defer m.summaryMu.Unlock()
if m.summary == nil {
return // no run in flight — nothing to summarise
}
m.summary.mu.Lock()
m.summary.attempted[app] = true
m.summary.failures = append(m.summary.failures, runFailure{App: app, Leg: leg, Reason: reason})
m.summary.mu.Unlock()
}
// emitRunSummary sends the digest, if and only if something failed.
//
// A CLEAN RUN EMITS NOTHING — not an empty digest. A nightly "0 failures" mail is an unread mail
// within a week, and it would also destroy the property the whole design rests on: that silence
// means the run finished and found nothing wrong.
func (m *Manager) emitRunSummary() {
m.summaryMu.Lock()
s := m.summary
m.summaryMu.Unlock()
if s == nil {
return
}
s.mu.Lock()
failures := append([]runFailure(nil), s.failures...)
attempted := len(s.attempted)
kind, runID := s.kind, s.runID
s.mu.Unlock()
if len(failures) == 0 {
return
}
// Stable order so two identical runs render identically and a diff of two mails is meaningful.
sort.Slice(failures, func(i, j int) bool {
if failures[i].App != failures[j].App {
return failures[i].App < failures[j].App
}
return failures[i].Leg < failures[j].Leg
})
if m.runSummaryNotify == nil {
// Nil-safe, but say so: an unwired seam here means the digest exists and reaches nobody,
// which is the built-but-never-wired failure this project has shipped four times.
m.logger.Printf("[WARN] [backup] %d app(s) failed in this %s run but no run-summary notifier is wired — "+
"the failures are recorded per app and NOT summarised to the operator", len(failures), kind)
return
}
apps := make([]RunFailureDetail, 0, len(failures))
names := make([]string, 0, len(failures))
for _, f := range failures {
apps = append(apps, RunFailureDetail{App: f.App, Leg: f.Leg, Reason: f.Reason})
names = append(names, f.App)
}
msg := fmt.Sprintf("%d of %d apps failed to back up in this %s run: %s",
len(failures), attempted, kind, strings.Join(dedupeStable(names), ", "))
m.logger.Printf("[INFO] [backup] Run summary: %d of %d apps failed (%s run) — notifying the operator once",
len(failures), attempted, kind)
m.runSummaryNotify(RunSummary{
RunID: runID,
RunKind: kind,
Failed: len(failures),
Attempted: attempted,
Apps: apps,
Usage: m.summaryUsage(),
Message: msg,
})
}
// summaryUsage reads the target filesystem once for the digest. It uses the same seam the reserve
// does, so a test states occupancy as an input rather than manufacturing it. Nil when unreadable —
// which the hub renders as "unavailable", never as zeros.
func (m *Manager) summaryUsage() *UnitSpace {
if m.stackProvider == nil {
return nil
}
for _, st := range m.stackProvider.ListDeployedStacks() {
if u := m.readUnitSpace(st.Name); u != nil {
return u
}
}
return nil
}
func dedupeStable(in []string) []string {
seen := map[string]bool{}
out := in[:0:0]
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
// RunFailureDetail is one app's failure as the hub receives it.
type RunFailureDetail struct {
App string `json:"app"`
Leg string `json:"leg"`
Reason string `json:"reason"`
}
// RunSummary is the digest payload handed to the notifier seam.
type RunSummary struct {
RunID string
RunKind string
Failed int
Attempted int
Apps []RunFailureDetail
Usage *UnitSpace
Message string
}
// SetRunSummaryNotify wires the per-run digest. INIT-ONLY — call once at startup in main.go,
// alongside SetUnitNotify. Nil-safe, but an unwired seam is logged loudly rather than being silently
// the old behaviour.
func (m *Manager) SetRunSummaryNotify(fn func(RunSummary)) {
m.runSummaryNotify = fn
}
// admissionReason returns the human reason this run refused an app, taken from the verdict the
// reserve already recorded. Reused rather than re-derived: the verdict carries the bound term, the
// estimate and the disk figures, and re-deriving them here would be a second source of truth for a
// sentence the operator reads.
func (m *Manager) admissionReason(stackName string) string {
m.admissionMu.Lock()
defer m.admissionMu.Unlock()
if set := m.admission; set != nil {
if v, ok := set.v[stackName]; ok && v.err != nil {
return v.err.Error()
}
}
return "refused by the capture reserve"
}
// runKindFor reports whether this run was the scheduled one or one a person triggered.
//
// The distinction is the operator's ruling: someone pressing the button is actively trying to get a
// backup, so a manual run must report even if the nightly one already wrote this hour. It is carried
// into the subject line because "the nightly run failed" and "the run I just asked for failed" are
// acted on differently.
func (m *Manager) runKindFor() string {
if m.manualRun.Load() {
return runKindManual
}
return runKindNightly
}
// MarkManualRun tags the NEXT backup run as operator-triggered. Called by the API/debug handlers
// that expose a "run backup now" control; the scheduled path leaves it alone.
func (m *Manager) MarkManualRun() { m.manualRun.Store(true) }
// newRunID returns a per-run identifier. It only has to be distinct between two runs on one box
// within the hub's 1-hour cooldown window, which a nanosecond clock reading satisfies; it is never
// persisted, compared across boxes, or used as a security value.
func newRunID() string {
return "run-" + strconv.FormatInt(time.Now().UnixNano(), 36)
}