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 }