88897a224e
gates / gates (push) Successful in 8s
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.
253 lines
11 KiB
Go
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
|
|
}
|