6be6c53e29
gates / gates (push) Successful in 25s
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
102 lines
3.8 KiB
Go
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
|
|
}
|