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/` 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/ 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 }