package backup import "sync" // InFlight is the host-wide "one heavy guest operation at a time" gate. // // R-85 (Scenario F). The operator's R-82 ruling was "one backup at a time per guest"; a restore-test // must JOIN that single-flight rather than sit outside it. It is not a lock-contention concern — // a restore-test uses a scratch VMID, so it never touches the live guest's vzdump lock. It is a // LINK concern: an offsite restore PULLS a multi-GB archive while an offsite backup PUSHES one, over // the same WireGuard tunnel. On the demo fleet that link runs at ~33 MB/min upstream; running both // at once makes each slower and pushes both toward their timeouts, which is how a healthy tier ends // up recorded as failed. // // It is deliberately host-wide and coarse rather than per-guest: these boxes carry one customer // guest, and the resource being protected (the uplink) is shared by everything on the host anyway. // // The gate is ADVISORY in one direction only — it never cancels anything already running. A caller // that cannot acquire DEFERS to its next cadence. Deferring a restore-test costs a few hours of // coverage; cancelling a running backup costs the backup. // // CORRECTED 2026-07-28 (F-A1). That "DEFERS" was true of the restore-test caller and NOT of the // backup caller, and the comment did not say so. The controller's start path had no 409 branch, so // a refusal here was recorded as a tier FAILURE: the R-88 breaker armed and the operator was // emailed "Whole-guest backup FAILED" about a backup that was merely waiting its turn. Campaign 8 // observed it on both demo boxes in the same minute. // // Fixed on the CONTROLLER side (v0.179.0), which is where the misreading lived — this gate's // behaviour was correct throughout and is unchanged. The controller now maps HTTP 409 to a // contention path: it defers the tier, keeps it DUE, and alarms only if contention outlives the // agent's own restore-test ceiling. Nothing here needs to change; the claim above is simply now // true of both callers. type InFlight struct { mu sync.Mutex what string // "" = idle } // TryAcquire claims the gate for `what`. ok=false means something else holds it, and `busy` names // it — the name matters, because "deferred" with no reason is indistinguishable from "broken". func (g *InFlight) TryAcquire(what string) (release func(), busy string, ok bool) { if g == nil { // Not wired (older call sites, tests) → no gating, previous behaviour. return func() {}, "", true } g.mu.Lock() defer g.mu.Unlock() if g.what != "" { return nil, g.what, false } g.what = what var once sync.Once return func() { once.Do(func() { g.mu.Lock() g.what = "" g.mu.Unlock() }) }, "", true } // Busy reports what currently holds the gate ("" = idle). func (g *InFlight) Busy() string { if g == nil { return "" } g.mu.Lock() defer g.mu.Unlock() return g.what }