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)
gates / gates (push) Successful in 25s
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
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user