Files
felhom-controller/controller/internal/backup/offbox_fit.go
T
admin 6be6c53e29
gates / gates (push) Successful in 25s
v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 10:31:55 +02:00

102 lines
3.8 KiB
Go

package backup
import (
"sort"
"time"
)
// ── Decision 50 (2026-09-30, R-720): will the apps fit the off-site quota? Say so BEFORE a push ──────────
//
// With every new app in the off-site copy by default, a household with a big photo library can select more
// than the customer's quota holds. What the box already does is SAFE and stays (measured 2026-09-30):
// * over the quota, NEW pushes are refused and only the ruled retention runs (`forget --keep-daily 7
// --keep-weekly 4 --keep-monthly 6 --prune`, the same policy as every night — pinned by
// TestDecision50_OverQuotaDeletesNothingExtra), so no history is deleted to make room;
// * an app whose files would cross the quota is pushed UNIT-ONLY (settings + database), with a warning.
// What was missing is the page saying it BEFOREHAND, with names and sizes, so the household chooses which
// apps stay off-site. The estimate is taken at the start of every off-site run and, in the background, when
// the page is opened and the last one is older than offsiteFitMaxAge; the page only ever reads the cache
// (a `du` over a photo library is never run in a page request).
// OffsiteAppSize is one app's estimated off-site size: its recovery unit plus its mandatory files.
type OffsiteAppSize struct {
Stack string
Bytes int64
}
// OffsiteFit is the estimate the page shows.
type OffsiteFit struct {
At time.Time
Apps []OffsiteAppSize // largest first
TotalBytes int64
QuotaBytes int64 // 0 = no quota (dedicated box or the household's own NAS) → always fits
Fits bool
}
const offsiteFitMaxAge = 6 * time.Hour
// estimateOffsiteFit is the pure rule: sum the apps' sizes and compare against the quota. An estimate of 0
// for an app (no unit yet) counts as 0 — it cannot make the verdict "does not fit" on its own.
func estimateOffsiteFit(apps []string, sizeOf func(stack string) int64, quotaGB int, now time.Time) OffsiteFit {
f := OffsiteFit{At: now, QuotaBytes: int64(quotaGB) * offboxGiB}
for _, a := range apps {
b := sizeOf(a)
f.Apps = append(f.Apps, OffsiteAppSize{Stack: a, Bytes: b})
f.TotalBytes += b
}
sort.SliceStable(f.Apps, func(i, j int) bool { return f.Apps[i].Bytes > f.Apps[j].Bytes })
f.Fits = f.QuotaBytes <= 0 || f.TotalBytes <= f.QuotaBytes
return f
}
// offsiteAppBytes: the recovery unit on disk + the mandatory off-site capture set (what a push carries).
func (m *Manager) offsiteAppBytes(stack string) int64 {
var n int64
if src, ok := m.discoverOffboxUnit(stack); ok {
n += m.offboxSize()(src)
}
extra, _, _ := m.offboxCaptureSet(stack)
for _, p := range extra {
n += m.offboxSize()(p)
}
return n
}
// RefreshOffsiteFit measures now (blocking) and caches the result.
func (m *Manager) RefreshOffsiteFit() OffsiteFit {
t := m.settings.GetOffboxTarget()
quota := 0
if t != nil {
quota = t.QuotaGB
}
f := estimateOffsiteFit(m.settings.GetOffboxApps(), m.offsiteAppBytes, quota, time.Now())
m.offsiteFitMu.Lock()
m.offsiteFit = f
m.offsiteFitMu.Unlock()
if !f.Fits {
m.logger.Printf("[WARN] [offbox] the apps selected for off-site (~%s) do not fit the quota (%d GB) — the page asks the household to choose",
humanizeBytes(f.TotalBytes), quota)
}
return f
}
// OffsiteFitForPage returns the cached estimate and, when it is missing or older than offsiteFitMaxAge,
// starts ONE background refresh. The page never waits on a measurement.
func (m *Manager) OffsiteFitForPage() OffsiteFit {
m.offsiteFitMu.Lock()
f := m.offsiteFit
stale := f.At.IsZero() || time.Since(f.At) > offsiteFitMaxAge
start := stale && !m.offsiteFitRunning
if start {
m.offsiteFitRunning = true
}
m.offsiteFitMu.Unlock()
if start {
go func() {
defer func() { m.offsiteFitMu.Lock(); m.offsiteFitRunning = false; m.offsiteFitMu.Unlock() }()
m.RefreshOffsiteFit()
}()
}
return f
}