Files
felhom-controller/controller/internal/backup/admission.go
T
admin 6c43bf6156
gates / gates (push) Successful in 9s
v0.193.1 — the refusal's size estimate is rendered in bytes, not "0.00 GiB" (R-181 follow-on)
Found by v0.193.0's own live proof run. The estimate was printed fixed to two
decimal GiB, so every app under ~10 MB rendered as "estimated 0.00 GiB write" —
which reads as "no estimate was available" and is the opposite of what happened.
Observed live on demo-hp 08:59:46: opengist's real 178 KB estimate printed as
0.00 GiB.

Shipped in the same session because it is the same defect class R-181 is about:
a message an operator cannot rely on is worse than no message.

The arithmetic is unchanged and still in GiB — the reserve's own unit, so the
comparison against FloorFreeGiB reads directly. Only the rendering moved to
humanizeBytes. estimatedWriteGiB -> estimatedWriteBytes, with the GiB conversion
done once at the point of comparison.
2026-08-03 11:05:02 +02:00

247 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)
}
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
}