Files
felhom-controller/controller/internal/backup/admission.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

253 lines
11 KiB
Go

package backup
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ── Backup admission (R-181) ─────────────────────────────────────────────────────────────────────
//
// WHAT WAS WRONG. B2's capture floor (v0.192.0, R-165) shipped as the deliberate replacement for the
// bulkhead the `mp1` partition used to give, and it was consulted in exactly ONE place —
// `captureAllRecoveryUnits`, which writes a manifest and three compose files: a few KB. The two legs
// that write the BULK into the same `backups/primary/<app>` tree — the database dump and the volume
// dump — ran FIRST and unguarded. Measured on demo-hp 2026-08-03 06:40:03: opengist's volume dump
// wrote 2.0 GB with no check, free fell to 1.0 GB, and the floor then refused the cheap write it had
// already lost the argument to. Its refusal message said *"the previous unit is untouched"*, which
// was false by then — that app's tar had gone 182,272 B → 2,147,666,432 B under a stale manifest.
//
// WHAT THIS IS. ONE verdict per app per run, taken before that app's FIRST write of the run, covering
// all three legs. The three write under one per-app root (`appbackup.RecoveryUnitPath`), which is
// exactly why one verdict can honestly cover them — and why the message may now claim what it claims.
//
// WHY IT IS DECIDED LAZILY AND NOT ONCE AT THE START OF THE RUN. Space changes during a run: app A's
// 2 GB dump can put app B under the reserve. A verdict taken at run start would wave B through on a
// reading that was true before the disk filled — the same class of mistake as the one being fixed,
// moved one level up.
//
// WHY IT IS REMEMBERED AND NOT RE-DECIDED PER LEG. Re-deciding between an app's own legs reintroduces
// the split this closes: the DB leg admitted, the volume leg admitted, the capture refused — with the
// bulk already written. Decide once, remember, reuse; reset per run, because a set carried between
// runs is a wrong answer with a confident face.
//
// IT REFUSES; IT NEVER DELETES. Unchanged from B2 and load-bearing: nothing on this filesystem is
// generational (one unit per app at one fixed path, refreshed in place), so "prune the oldest" could
// only mean destroying a DIFFERENT app's only local copy. `pruneStalePrimaryDirs` removes ORPHANED
// dirs an app left on a drive it moved off — it has no notion of age or of the current app — and must
// never be repurposed for headroom.
// floorReason records WHICH term bound, so the operator can tell "the disk is full" from "this app's
// backup is too big for what is left". An alert that says only "refused" sends them to read code.
type floorReason int
const (
floorAdmit floorReason = iota // admitted — no term binds
floorHeadroom // the filesystem is ALREADY at/below the reserve
floorSize // there is room now, but this app's own write would cross the reserve
)
func (r floorReason) String() string {
switch r {
case floorHeadroom:
return "headroom"
case floorSize:
return "size"
default:
return "admitted"
}
}
// admissionVerdict is one app's decision for one run. It carries everything the alert needs, so the
// alert is rendered once from the same value every leg consults.
type admissionVerdict struct {
admitted bool
reason floorReason
usage *UnitSpace
estGiB float64 // the estimated write in GiB — the arithmetic unit, matching the reserve's terms
estBytes int64 // the same estimate in bytes — the RENDERING unit; see floorRefusal
hasEst bool // whether an estimate was available at all (§8.2: distinct from "estimated 0")
err error // the refusal, nil when admitted
}
// admissionSet is the per-RUN memo. Deliberately not a field with a lifetime of its own: it is
// created by beginAdmissionRun and cleared by the returned func, so an absent set means "no run is in
// flight" rather than "a stale answer from last night".
type admissionSet struct {
v map[string]admissionVerdict
}
// beginAdmissionRun opens the per-run admission scope and returns the closer. Called once at the top
// of runDBDumpsInternal — which is the single orchestrator of all three legs — so the DB dump, the
// volume dump and the capture of one app all consult the SAME verdict.
//
// A second call while a set is live REPLACES it and the returned closer restores the previous one, so
// nesting cannot silently drop a caller's scope.
func (m *Manager) beginAdmissionRun() func() {
m.admissionMu.Lock()
prev := m.admission
m.admission = &admissionSet{v: map[string]admissionVerdict{}}
m.admissionMu.Unlock()
return func() {
m.admissionMu.Lock()
m.admission = prev
m.admissionMu.Unlock()
}
}
// admitApp is THE gate. It returns true when this app may write, false when the reserve refuses it.
//
// On the first refusal for an app it logs and fires EXACTLY ONE operator alert; every later leg in
// the same run reads the memo and stays silent, so a refused app produces one email and not three.
//
// With no run scope open (the periodic status refresh calls captureAllRecoveryUnits directly) it
// decides fresh. That is not a gap: each app appears once in that sweep, so "once per app" still
// holds — there is simply nothing to remember it across.
func (m *Manager) admitApp(stackName string) bool {
m.admissionMu.Lock()
defer m.admissionMu.Unlock()
if set := m.admission; set != nil {
if v, ok := set.v[stackName]; ok {
return v.admitted // already decided this run — do NOT re-decide, do NOT re-alert
}
}
v := m.decideAdmission(stackName)
if set := m.admission; set != nil {
set.v[stackName] = v
}
if v.admitted {
return true
}
// The claim below is now literally true, and that is the whole point of R-181: the verdict is
// taken before the FIRST of the three writes, so at this moment nothing under
// backups/primary/<app> has been touched by this run. TestAdmission_RefusedAppsTreeIsByteIdentical
// pins the consequence by checksumming the tree, not by reading this line.
m.logger.Printf("[WARN] [backup] App backup REFUSED for %s (%s) — %v; NO database dump, NO volume "+
"dump and NO recovery-unit capture was written for it, the previous unit is untouched and "+
"NOTHING was deleted", stackName, v.reason, v.err)
if m.unitNotify != nil {
m.unitNotify(stackName, v.err, v.usage)
}
// R-182: the digest entry is recorded HERE, where the verdict is taken — once per app per run.
// Not at the three call sites that consult the memo: R-181's whole contract is that ONE verdict
// covers all three legs, so noting it per leg listed a single refused app three times and
// produced counts like "2 of 1 apps failed". The leg name says what actually happened, which is
// that nothing was attempted at all.
m.noteFailure(stackName, "whole app (refused before any write)", v.err.Error())
return false
}
// decideAdmission applies the floor to a fresh reading plus this app's estimated write.
func (m *Manager) decideAdmission(stackName string) admissionVerdict {
estBytes, hasEst := m.estimatedWriteBytes(stackName)
estGiB := float64(estBytes) / (1024 * 1024 * 1024)
usage, reason := m.floorVerdict(m.readUnitSpace(stackName), estGiB)
v := admissionVerdict{
admitted: reason == floorAdmit,
reason: reason,
usage: usage,
estGiB: estGiB,
estBytes: estBytes,
hasEst: hasEst,
}
if !v.admitted {
v.err = floorRefusal(reason, usage, estBytes, hasEst)
}
return v
}
// floorRefusal renders the refusal an operator reads. It names the reserve (not an I/O error — this
// is a deliberate hold, not broken machinery), says WHICH term bound, and states plainly when the
// decision was headroom-only because the app has no previous backup to estimate from (§8.2).
//
// THE ESTIMATE IS RENDERED IN BYTES-HUMANIZED, NOT GiB, and that is not cosmetic. Fixed to two
// decimal GiB, every app under ~10 MB prints `0.00 GiB` — which reads as "no estimate was available"
// and is the opposite of what happened. Observed on the live proof run: opengist's real 178 KB
// estimate rendered as `estimated 0.00 GiB write`. The arithmetic stays in GiB (the reserve's own
// unit); only the rendering changes.
func floorRefusal(reason floorReason, usage *UnitSpace, estBytes int64, hasEst bool) error {
var b strings.Builder
fmt.Fprintf(&b, "%%w (reserve: %.0f%%%% used or %.1f GiB free", FloorUsedPercent, FloorFreeGiB)
switch {
case reason == floorSize:
fmt.Fprintf(&b, "; this app's last backup was %s and writing it again would cross the reserve", humanizeBytes(estBytes))
case hasEst:
fmt.Fprintf(&b, "; the filesystem is already below it, before this app's estimated %s write", humanizeBytes(estBytes))
default:
b.WriteString("; this app has no previous backup on disk, so only current headroom was considered")
}
b.WriteString(") — %s")
return fmt.Errorf(b.String(), ErrCaptureFloor, usage)
}
// estimatedWriteBytes estimates what this app's three legs are about to write, from what the PREVIOUS
// run left in its unit: the `.sql` dumps and the `.tar` volume archives already on disk for this app.
//
// WHY THIS ESTIMATOR. It is free — two ReadDirs of a directory the caller is about to write into — and
// the next write is usually close to the last one. The alternative, a container-based `du` of every
// named volume, was measured on the demo box before being rejected; the figure is in REPORT.md §6.
//
// NO HISTORY → (0, false), and the caller falls back to headroom-only. Refusing an app because it has
// never been backed up would make the first backup the one that can never happen (Scenario E).
//
// It reads the app's CURRENT unit root, so an app that moved drives estimates from its new (probably
// empty) location and is treated as history-less — conservative in the admitting direction, which is
// the right way round for an estimate that only ever tightens a threshold.
func (m *Manager) estimatedWriteBytes(stackName string) (int64, bool) {
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" {
return 0, false
}
nsRoot := m.namespaceRoot(drivePath)
var total int64
var found bool
for _, d := range []struct {
dir string
ext string
}{
{AppDBDumpPath(nsRoot, stackName), ".sql"},
{AppVolumeDumpPath(nsRoot, stackName), ".tar"},
} {
n, ok := sumFileSizes(d.dir, d.ext)
total += n
found = found || ok
}
if !found {
return 0, false
}
return total, true
}
// sumFileSizes totals the sizes of files with the given suffix in dir. The bool reports whether ANY
// such file was seen — distinct from a zero total, because a 0-byte dump is history (a real, if
// alarming, previous result) while an absent directory is not.
//
// A stat error on one entry is skipped rather than aborting the sum: an estimate built from the
// readable files is worth more than no estimate, and the entry that could not be read is logged
// nowhere because this is a hint, not a measurement — it can only tighten a threshold, never relax
// one below what the headroom term already enforces.
func sumFileSizes(dir, suffix string) (int64, bool) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, false
}
var total int64
var found bool
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), suffix) {
continue
}
fi, err := os.Stat(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
found = true
total += fi.Size()
}
return total, found
}